diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 1034a745..8e13d711 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -11,13 +11,16 @@ on: jobs: python-tests: runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8, 3.12] steps: - name: "Checkout branch" uses: actions/checkout@v4 - name: "Set up Python on Ubuntu" uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: ${{ matrix.python-version }} - name: "Python codestyle" run: | pip install ".[dev]" diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb56d74..2e6c8409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Change the jslink target trait from `target` to `shared_target` (#80) - Change the jslink fov trait from `fov` to `shared_fov` (#83) +- Deprecate the `add_listener` method for a preferred use of `set_listener` method (#82) ## [0.3.0] diff --git a/examples/10_Advanced-GUI.ipynb b/examples/10_Advanced-GUI.ipynb index dfdab0bc..8ce02836 100644 --- a/examples/10_Advanced-GUI.ipynb +++ b/examples/10_Advanced-GUI.ipynb @@ -36,7 +36,14 @@ ")\n", "\n", "\n", - "def on_survey_value_change(change):\n", + "def on_survey_value_change(change: dict) -> None:\n", + " \"\"\"Survey change callback.\n", + "\n", + " Parameters\n", + " ----------\n", + " change : dict\n", + " The change dictionary.\n", + " \"\"\"\n", " aladin.survey = change[\"new\"]\n", "\n", "\n", @@ -63,7 +70,14 @@ ")\n", "\n", "\n", - "def on_survey_overlay_value_change(change):\n", + "def on_survey_overlay_value_change(change: dict) -> None:\n", + " \"\"\"Survey overlay change callback.\n", + "\n", + " Parameters\n", + " ----------\n", + " change : dict\n", + " The change dictionary.\n", + " \"\"\"\n", " aladin.overlay_survey = change[\"new\"]\n", " aladin.overlay_survey_opacity = aladin.overlay_survey_opacity + 0.00000001\n", "\n", @@ -84,7 +98,14 @@ ")\n", "\n", "\n", - "def on_surveyoverlay_opacity_value_change(change):\n", + "def on_surveyoverlay_opacity_value_change(change: dict) -> None:\n", + " \"\"\"Survey overlay opacity change callback.\n", + "\n", + " Parameters\n", + " ----------\n", + " change : dict\n", + " The change dictionary.\n", + " \"\"\"\n", " aladin.overlay_survey_opacity = change[\"new\"]\n", "\n", "\n", @@ -105,7 +126,14 @@ ")\n", "\n", "\n", - "def on_zoom_slider_value_change(change):\n", + "def on_zoom_slider_value_change(change: dict) -> None:\n", + " \"\"\"Zoom slider change callback.\n", + "\n", + " Parameters\n", + " ----------\n", + " change : dict\n", + " The change dictionary.\n", + " \"\"\"\n", " aladin.fov = 180 / change[\"new\"]\n", "\n", "\n", diff --git a/examples/2_Base_Commands.ipynb b/examples/2_Base_Commands.ipynb index 9b174384..855f6e25 100644 --- a/examples/2_Base_Commands.ipynb +++ b/examples/2_Base_Commands.ipynb @@ -71,6 +71,13 @@ "aladin.target = \"sgr a*\"" ] }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "aladin.target" + }, { "cell_type": "code", "execution_count": null, @@ -122,6 +129,32 @@ "source": [ "aladin.coo_frame" ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Some commands can be used with astropy objects" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "from astropy.coordinates import Angle, SkyCoord" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "aladin.target = SkyCoord(\"12h00m00s\", \"-30d00m00s\", frame=\"icrs\")" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "aladin.fov = Angle(5, \"deg\")" } ], "metadata": { diff --git a/examples/3_Functions.ipynb b/examples/3_Functions.ipynb index cb1f3ae3..2f25433a 100644 --- a/examples/3_Functions.ipynb +++ b/examples/3_Functions.ipynb @@ -53,7 +53,7 @@ " \"http://vizier.u-strasbg.fr/viz-bin/votable?-source=HIP2&-c=LMC&-out.add=_RAJ,_\"\n", " \"DEJ&-oc.form=dm&-out.meta=DhuL&-out.max=9999&-c.rm=180\"\n", ")\n", - "options = {\"sourceSize\": 12, \"color\": \"#f08080\", \"onClick\": \"showTable\"}\n", + "options = {\"source_size\": 12, \"color\": \"#f08080\", \"on_click\": \"showTable\"}\n", "aladin.add_catalog_from_URL(url, options)" ] }, @@ -79,12 +79,26 @@ "metadata": {}, "outputs": [], "source": [ - "def getObjectData(data):\n", + "def get_object_data(data: dict) -> dict:\n", + " \"\"\"Print the clicked object data.\n", + "\n", + " Parameters\n", + " ----------\n", + " data : dict\n", + " The data of the clicked object.\n", + " \"\"\"\n", " print(\"It clicked.\")\n", " return data\n", "\n", "\n", - "def getObjectRaDecProduct(data):\n", + "def get_object_ra_dec_product(data: dict) -> float:\n", + " \"\"\"Return the product of the ra and dec values of the clicked object.\n", + "\n", + " Parameters\n", + " ----------\n", + " data : dict\n", + " The data of the clicked object.\n", + " \"\"\"\n", " return data[\"ra\"] * data[\"dec\"]\n", "\n", "\n", @@ -92,8 +106,8 @@ "# json object whose parameter data will be used by the python functions\n", "# (data is a literal object on the js side, it will be converted as a dictionary\n", "# object on the python side)\n", - "aladin.add_listener(\"object_hovered\", getObjectRaDecProduct)\n", - "aladin.add_listener(\"object_clicked\", getObjectData)" + "aladin.set_listener(\"object_hovered\", get_object_ra_dec_product)\n", + "aladin.set_listener(\"object_clicked\", get_object_data)" ] }, { diff --git a/examples/4_Importing_Tables.ipynb b/examples/4_Importing_Tables.ipynb index 5dcb5d50..cd5df9a5 100644 --- a/examples/4_Importing_Tables.ipynb +++ b/examples/4_Importing_Tables.ipynb @@ -58,7 +58,8 @@ "metadata": {}, "outputs": [], "source": [ - "aladin.add_table(table, shape=\"rhomb\", color=\"lightskyblue\", sourceSize=20)" + "aladin.add_table(table, shape=\"rhomb\", color=\"lightskyblue\", source_size=20)\n", + "# This line also works with camelCase instead of snake_case: sourceSize=20" ] }, { diff --git a/examples/5_Display_a_MOC.ipynb b/examples/5_Display_a_MOC.ipynb index 51ae9e8f..61058dcc 100644 --- a/examples/5_Display_a_MOC.ipynb +++ b/examples/5_Display_a_MOC.ipynb @@ -284,7 +284,9 @@ " external_radius=20 * u.deg,\n", " max_depth=16,\n", ")\n", - "aladin.add_moc(moc, color=\"teal\", edge=True, lineWidth=3, fillColor=\"teal\", opacity=0.5)" + "aladin.add_moc(\n", + " moc, color=\"teal\", edge=True, line_width=3, fill_color=\"teal\", opacity=0.5\n", + ")" ] } ], diff --git a/examples/6_Linked-widgets.ipynb b/examples/6_Linked-widgets.ipynb index f31212f9..1ce40493 100644 --- a/examples/6_Linked-widgets.ipynb +++ b/examples/6_Linked-widgets.ipynb @@ -27,12 +27,12 @@ "c = Aladin(layout=Layout(width=\"33.33%\"), survey=\"P/2MASS/color\", **cosmetic_options)\n", "\n", "# synchronize target between 3 widgets\n", - "widgets.jslink((a, \"shared_target\"), (b, \"shared_target\"))\n", - "widgets.jslink((b, \"shared_target\"), (c, \"shared_target\"))\n", + "widgets.jslink((a, \"_target\"), (b, \"_target\"))\n", + "widgets.jslink((b, \"_target\"), (c, \"_target\"))\n", "\n", "# synchronize FoV (zoom level) between 3 widgets\n", - "widgets.jslink((a, \"shared_fov\"), (b, \"shared_fov\"))\n", - "widgets.jslink((b, \"shared_fov\"), (c, \"shared_fov\"))\n", + "widgets.jslink((a, \"_fov\"), (b, \"_fov\"))\n", + "widgets.jslink((b, \"_fov\"), (c, \"_fov\"))\n", "\n", "items = [a, b, c]\n", "\n", diff --git a/examples/7_on-click-callback.ipynb b/examples/7_on-click-callback.ipynb index 90b1af0e..814c7e21 100644 --- a/examples/7_on-click-callback.ipynb +++ b/examples/7_on-click-callback.ipynb @@ -37,7 +37,14 @@ "metadata": {}, "outputs": [], "source": [ - "def process_result(data):\n", + "def process_result(data: dict) -> None:\n", + " \"\"\"Process the result of a click event on the Aladin widget.\n", + "\n", + " Parameters\n", + " ----------\n", + " data : dict\n", + " The data returned by the click event.\n", + " \"\"\"\n", " info.value = \"\"\n", " ra = data[\"ra\"]\n", " dec = data[\"dec\"]\n", @@ -78,7 +85,7 @@ " info.value = \"

%s



%s\" % (obj_name, sed_img)\n", "\n", "\n", - "aladin.add_listener(\"click\", process_result)" + "aladin.set_listener(\"click\", process_result)" ] }, { diff --git a/examples/8_Rectangular-selection.ipynb b/examples/8_Rectangular-selection.ipynb index 46244e47..7bff0560 100644 --- a/examples/8_Rectangular-selection.ipynb +++ b/examples/8_Rectangular-selection.ipynb @@ -33,7 +33,16 @@ "button = widgets.Button(description=\"Select\")\n", "\n", "\n", - "def on_button_clicked(b): # noqa: ARG001\n", + "def on_button_clicked(_: any) -> None:\n", + " \"\"\"Button click event callback.\n", + "\n", + " It will trigger the rectangular selection in the Aladin widget.\n", + "\n", + " Parameters\n", + " ----------\n", + " _: any\n", + " The button widget that triggered the event.\n", + " \"\"\"\n", " aladin.rectangular_selection()\n", "\n", "\n", @@ -61,7 +70,14 @@ "aladin.add_table(table)\n", "\n", "\n", - "def process_result(sources):\n", + "def process_result(sources: dict) -> None:\n", + " \"\"\"Process the sources selected in the Aladin widget and display them in the table.\n", + "\n", + " Parameters\n", + " ----------\n", + " sources: dict\n", + " The sources selected in the Aladin widget.\n", + " \"\"\"\n", " s = ''\n", " s += \"\"\n", " for source in sources:\n", @@ -74,7 +90,7 @@ " table_info.value = s\n", "\n", "\n", - "aladin.add_listener(\"select\", process_result)" + "aladin.set_listener(\"select\", process_result)" ] } ], diff --git a/js/aladin_lite.js b/js/aladin_lite.js new file mode 100644 index 00000000..27c2fa99 --- /dev/null +++ b/js/aladin_lite.js @@ -0,0 +1,3 @@ +import A from "https://esm.sh/aladin-lite@3.4.0-beta"; + +export default A; diff --git a/js/models/event_handler.js b/js/models/event_handler.js new file mode 100644 index 00000000..3c88e812 --- /dev/null +++ b/js/models/event_handler.js @@ -0,0 +1,209 @@ +import MessageHandler from "./message_handler"; +import { Lock } from "../utils"; + +export default class EventHandler { + /** + * Constructor for the EventHandler class. + * @param aladin The Aladin instance + * @param aladinDiv The Aladin div + * @param model The model instance + */ + constructor(aladin, aladinDiv, model) { + this.aladin = aladin; + this.aladinDiv = aladinDiv; + this.model = model; + this.messageHandler = new MessageHandler(aladin); + } + + /** + * Subscribes to all the events needed for the Aladin Lite widget. + */ + subscribeAll() { + /* ------------------- */ + /* Listeners --------- */ + /* ------------------- */ + + /* Position Control */ + // there are two ways of changing the target, one from the javascript side, and + // one from the python side. We have to instantiate two listeners for these, but + // the gotoObject call should only happen once. The two booleans prevent the two + // listeners from triggering each other and creating a buggy loop. The same trick + // is also necessary for the field of view. + + /* Target control */ + const jsTargetLock = new Lock(); + const pyTargetLock = new Lock(); + + // Event triggered when the user moves the map in Aladin Lite + this.aladin.on("positionChanged", (position) => { + if (pyTargetLock.locked) { + pyTargetLock.unlock(); + return; + } + jsTargetLock.lock(); + const raDec = [position.ra, position.dec]; + // const raDec = this.aladin.getRaDec(); + this.model.set("_target", `${raDec[0]} ${raDec[1]}`); + this.model.save_changes(); + }); + + this.model.on("change:_target", () => { + if (jsTargetLock.locked) { + jsTargetLock.unlock(); + return; + } + pyTargetLock.lock(); + let target = this.model.get("_target"); + const [ra, dec] = target.split(" "); + this.aladin.gotoRaDec(ra, dec); + }); + + /* Field of View control */ + const jsFovLock = new Lock(); + const pyFovLock = new Lock(); + + this.aladin.on("zoomChanged", (fov) => { + if (pyFovLock.locked) { + pyFovLock.unlock(); + return; + } + jsFovLock.lock(); + // fov MUST be cast into float in order to be sent to the model + this.model.set("_fov", parseFloat(fov.toFixed(5))); + this.model.save_changes(); + }); + + this.model.on("change:_fov", () => { + if (jsFovLock.locked) { + jsFovLock.unlock(); + return; + } + pyFovLock.lock(); + let fov = this.model.get("_fov"); + this.aladin.setFoV(fov); + }); + + /* Div control */ + this.model.on("change:height", () => { + let height = this.model.get("height"); + this.aladinDiv.style.height = `${height}px`; + }); + + /* Aladin callbacks */ + + this.aladin.on("objectHovered", (object) => { + if (object["data"] !== undefined) { + this.model.send({ + event_type: "object_hovered", + content: { + ra: object["ra"], + dec: object["dec"], + }, + }); + } + }); + + this.aladin.on("objectClicked", (clicked) => { + let clickedContent = { + ra: clicked["ra"], + dec: clicked["dec"], + }; + if (clicked["data"] !== undefined) { + clickedContent["data"] = clicked["data"]; + } + this.model.set("clicked", clickedContent); + // send a custom message in case the user wants to define their own callbacks + this.model.send({ + event_type: "object_clicked", + content: clickedContent, + }); + this.model.save_changes(); + }); + + this.aladin.on("click", (clickContent) => { + this.model.send({ + event_type: "click", + content: clickContent, + }); + }); + + this.aladin.on("select", (catalogs) => { + let objectsData = []; + // TODO: this flattens the selection. Each object from different + // catalogs are entered in the array. To change this, maybe change + // upstream what is returned upon selection? + catalogs.forEach((catalog) => { + catalog.forEach((object) => { + objectsData.push({ + ra: object.ra, + dec: object.dec, + data: object.data, + x: object.x, + y: object.y, + }); + }); + }); + this.model.send({ + event_type: "select", + content: objectsData, + }); + }); + + /* Aladin functionalities */ + + this.model.on("change:coo_frame", () => { + this.aladin.setFrame(this.model.get("coo_frame")); + }); + + this.model.on("change:survey", () => { + this.aladin.setImageSurvey(this.model.get("survey")); + }); + + this.model.on("change:overlay_survey", () => { + this.aladin.setOverlayImageLayer(this.model.get("overlay_survey")); + }); + + this.model.on("change:overlay_survey_opacity", () => { + this.aladin + .getOverlayImageLayer() + .setAlpha(this.model.get("overlay_survey_opacity")); + }); + + this.eventHandlers = { + change_fov: this.messageHandler.handleChangeFoV, + goto_ra_dec: this.messageHandler.handleGotoRaDec, + add_catalog_from_URL: this.messageHandler.handleAddCatalogFromURL, + add_MOC_from_URL: this.messageHandler.handleAddMOCFromURL, + add_MOC_from_dict: this.messageHandler.handleAddMOCFromDict, + add_overlay_from_stcs: this.messageHandler.handleAddOverlayFromSTCS, + change_colormap: this.messageHandler.handleChangeColormap, + get_JPG_thumbnail: this.messageHandler.handleGetJPGThumbnail, + trigger_rectangular_selection: + this.messageHandler.handleTriggerRectangularSelection, + add_table: this.messageHandler.handleAddTable, + }; + + this.model.on("msg:custom", (msg, buffers) => { + const eventName = msg["event_name"]; + const handler = this.eventHandlers[eventName]; + if (handler) handler.call(this, msg, buffers); + else throw new Error(`Unknown event name: ${eventName}`); + }); + } + + /** + * Unsubscribe from all the model events. + * There is no need to unsubscribe from the Aladin Lite events. + */ + unsubscribeAll() { + this.model.off("change:_target"); + this.model.off("change:_fov"); + this.model.off("change:height"); + this.model.off("change:coo_frame"); + this.model.off("change:survey"); + this.model.off("change:overlay_survey"); + this.model.off("change:overlay_survey_opacity"); + this.model.off("change:trigger_event"); + this.model.off("msg:custom"); + } +} diff --git a/js/models/message_handler.js b/js/models/message_handler.js new file mode 100644 index 00000000..3dfff7b2 --- /dev/null +++ b/js/models/message_handler.js @@ -0,0 +1,70 @@ +import { convertOptionNamesToCamelCase } from "../utils"; +import A from "../aladin_lite"; + +export default class MessageHandler { + constructor(aladin) { + this.aladin = aladin; + } + + handleChangeFoV(msg) { + this.aladin.setFoV(msg["fov"]); + } + + handleGotoRaDec(msg) { + this.aladin.gotoRaDec(msg["ra"], msg["dec"]); + } + + handleAddCatalogFromURL(msg) { + const options = convertOptionNamesToCamelCase(msg["options"] || {}); + this.aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], options)); + } + + handleAddMOCFromURL(msg) { + const options = convertOptionNamesToCamelCase(msg["options"] || {}); + this.aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options)); + } + + handleAddMOCFromDict(msg) { + const options = convertOptionNamesToCamelCase(msg["options"] || {}); + this.aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options)); + } + + handleAddOverlayFromSTCS(msg) { + const overlayOptions = convertOptionNamesToCamelCase( + msg["overlay_options"] || {}, + ); + const stcString = msg["stc_string"]; + const overlay = A.graphicOverlay(overlayOptions); + this.aladin.addOverlay(overlay); + overlay.addFootprints(A.footprintsFromSTCS(stcString)); + } + + handleChangeColormap(msg) { + this.aladin.getBaseImageLayer().setColormap(msg["colormap"]); + } + + handleGetJPGThumbnail() { + this.aladin.exportAsPNG(); + } + + handleTriggerRectangularSelection() { + this.aladin.select(); + } + + handleAddTable(msg, buffers) { + const options = convertOptionNamesToCamelCase(msg["options"] || {}); + const buffer = buffers[0].buffer; + const decoder = new TextDecoder("utf-8"); + const blob = new Blob([decoder.decode(buffer)]); + const url = URL.createObjectURL(blob); + A.catalogFromURL( + url, + Object.assign(options, { onClick: "showTable" }), + (catalog) => { + this.aladin.addCatalog(catalog); + }, + false, + ); + URL.revokeObjectURL(url); + } +} diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 00000000..746ba336 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,44 @@ +/** + * Converts a string from camelCase to snake_case. + * @param {string} snakeCaseStr - The string to convert. + * @returns {string} The string converted to snake_case. + */ +function snakeCaseToCamelCase(snakeCaseStr) { + if (snakeCaseStr.charAt(0) === "_") snakeCaseStr = snakeCaseStr.slice(1); + let temp = snakeCaseStr.split("_"); + for (let i = 1; i < temp.length; i++) + temp[i] = temp[i].charAt(0).toUpperCase() + temp[i].slice(1); + return temp.join(""); +} + +/** + * Converts option names in an object from snake_case to camelCase. + * @param {Object} options - The options object with snake_case property names. + * @returns {Object} An object with property names converted to camelCase. + */ +function convertOptionNamesToCamelCase(options) { + const newOptions = {}; + for (const optionName in options) + newOptions[snakeCaseToCamelCase(optionName)] = options[optionName]; + return newOptions; +} + +class Lock { + locked = false; + + /** + * Locks the object + */ + unlock() { + this.locked = false; + } + + /** + * Unlocks the object + */ + lock() { + this.locked = true; + } +} + +export { snakeCaseToCamelCase, convertOptionNamesToCamelCase, Lock }; diff --git a/js/widget.js b/js/widget.js index 3d997108..7b261dde 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,277 +1,50 @@ -import A from "https://esm.sh/aladin-lite@3.4.0-beta"; import "./widget.css"; +import EventHandler from "./models/event_handler"; +import { snakeCaseToCamelCase } from "./utils"; +import A from "./aladin_lite"; let idxView = 0; -function convert_pyname_to_jsname(pyname) { - if (pyname.charAt(0) === "_") pyname = pyname.slice(1); - let temp = pyname.split("_"); - for (let i = 1; i < temp.length; i++) { - temp[i] = temp[i].charAt(0).toUpperCase() + temp[i].slice(1); - } - return temp.join(""); -} - -async function initialize({ model }) { - await A.init; -} - -function render({ model, el }) { - /* ------------------- */ - /* View -------------- */ - /* ------------------- */ - - let init_options = {}; +function initAladinLite(model, el) { + let initOptions = {}; model.get("init_options").forEach((option_name) => { - init_options[convert_pyname_to_jsname(option_name)] = - model.get(option_name); + initOptions[snakeCaseToCamelCase(option_name)] = model.get(option_name); }); let aladinDiv = document.createElement("div"); aladinDiv.classList.add("aladin-widget"); - aladinDiv.style.height = `${init_options["height"]}px`; + aladinDiv.style.height = `${initOptions["height"]}px`; aladinDiv.id = `aladin-lite-div-${idxView}`; - let aladin = new A.aladin(aladinDiv, init_options); + let aladin = new A.aladin(aladinDiv, initOptions); idxView += 1; - const ra_dec = init_options["target"].split(" "); - aladin.gotoRaDec(ra_dec[0], ra_dec[1]); + // Set the target again after the initialization to be sure that the target is set + // from icrs coordinates because of the use of gotoObject in the Aladin Lite API + const raDec = initOptions["target"].split(" "); + aladin.gotoRaDec(raDec[0], raDec[1]); el.appendChild(aladinDiv); + return { aladin, aladinDiv }; +} + +async function initialize({ model }) { + await A.init; +} +function render({ model, el }) { /* ------------------- */ - /* Listeners --------- */ + /* View -------------- */ /* ------------------- */ - /* Position Control */ - // there are two ways of changing the target, one from the javascript side, and - // one from the python side. We have to instantiate two listeners for these, but - // the gotoObject call should only happen once. The two booleans prevent the two - // listeners from triggering each other and creating a buggy loop. The same trick - // is also necessary for the field of view. - - /* Target control */ - let target_js = false; - let target_py = false; + const { aladin, aladinDiv } = initAladinLite(model, el); - // Event triggered when the user moves the map in Aladin Lite - aladin.on("positionChanged", () => { - if (target_py) { - target_py = false; - return; - } - target_js = true; - const ra_dec = aladin.getRaDec(); - model.set("_target", `${ra_dec[0]} ${ra_dec[1]}`); - model.set("shared_target", `${ra_dec[0]} ${ra_dec[1]}`); - model.save_changes(); - }); - - // Event triggered when the target is changed from the Python side using jslink - model.on("change:shared_target", () => { - if (target_js) { - target_js = false; - return; - } - target_py = true; - const target = model.get("shared_target"); - const [ra, dec] = target.split(" "); - aladin.gotoRaDec(ra, dec); - }); - - /* Field of View control */ - let fov_py = false; - let fov_js = false; - - aladin.on("zoomChanged", (fov) => { - if (fov_py) { - fov_py = false; - return; - } - fov_js = true; - // fov MUST be cast into float in order to be sent to the model - model.set("_fov", parseFloat(fov.toFixed(5))); - model.set("shared_fov", parseFloat(fov.toFixed(5))); - model.save_changes(); - }); - - model.on("change:shared_fov", () => { - if (fov_js) { - fov_js = false; - return; - } - fov_py = true; - let fov = model.get("shared_fov"); - aladin.setFoV(fov); - }); - - /* Div control */ - - model.on("change:height", () => { - let height = model.get("height"); - aladinDiv.style.height = `${height}px`; - }); - - /* Aladin callbacks */ - - aladin.on("objectHovered", (object) => { - if (object["data"] != undefined) { - model.send({ - event_type: "object_hovered", - content: { - ra: object["ra"], - dec: object["dec"], - }, - }); - } - }); - - aladin.on("objectClicked", (clicked) => { - let clicked_content = { - ra: clicked["ra"], - dec: clicked["dec"], - }; - if (clicked["data"] !== undefined) { - clicked_content["data"] = clicked["data"]; - } - model.set("clicked", clicked_content); - // send a custom message in case the user wants to define their own callbacks - model.send({ - event_type: "object_clicked", - content: clicked_content, - }); - model.save_changes(); - }); - - aladin.on("click", (click_content) => { - model.send({ - event_type: "click", - content: click_content, - }); - }); - - aladin.on("select", (catalogs) => { - let objects_data = []; - // TODO: this flattens the selection. Each object from different - // catalogs are entered in the array. To change this, maybe change - // upstream what is returned upon selection? - catalogs.forEach((catalog) => { - catalog.forEach((object) => { - objects_data.push({ - ra: object.ra, - dec: object.dec, - data: object.data, - x: object.x, - y: object.y, - }); - }); - }); - model.send({ - event_type: "select", - content: objects_data, - }); - }); - - /* Aladin functionalities */ - - model.on("change:coo_frame", () => { - aladin.setFrame(model.get("coo_frame")); - }); - - model.on("change:survey", () => { - aladin.setImageSurvey(model.get("survey")); - }); - - model.on("change:overlay_survey", () => { - aladin.setOverlayImageLayer(model.get("overlay_survey")); - }); - - model.on("change:overlay_survey_opacity", () => { - aladin.getOverlayImageLayer().setAlpha(model.get("overlay_survey_opacity")); - }); - - model.on("msg:custom", (msg) => { - let options = {}; - switch (msg["event_name"]) { - case "change_fov": - aladin.setFoV(msg["fov"]); - break; - case "goto_ra_dec": - const ra = msg["ra"]; - const dec = msg["dec"]; - aladin.gotoRaDec(ra, dec); - break; - case "add_catalog_from_URL": - aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], msg["options"])); - break; - case "add_MOC_from_URL": - // linewidth = 3 is easier to see than the default 1 from upstream - options = msg["options"]; - if (options["lineWidth"] === undefined) { - options["lineWidth"] = 3; - } - aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options)); - break; - case "add_MOC_from_dict": - // linewidth = 3 is easier to see than the default 1 from upstream - options = msg["options"]; - if (options["lineWidth"] === undefined) { - options["lineWidth"] = 3; - } - aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options)); - break; - case "add_overlay_from_stcs": - let overlay = A.graphicOverlay(msg["overlay_options"]); - aladin.addOverlay(overlay); - overlay.addFootprints(A.footprintsFromSTCS(msg["stc_string"])); - break; - case "change_colormap": - aladin.getBaseImageLayer().setColormap(msg["colormap"]); - break; - case "get_JPG_thumbnail": - aladin.exportAsPNG(); - break; - case "trigger_rectangular_selection": - aladin.select(); - break; - case "add_table": - let table_bytes = model.get("_table"); - let decoder = new TextDecoder("utf-8"); - let blob = new Blob([decoder.decode(table_bytes)]); - let url = URL.createObjectURL(blob); - A.catalogFromURL( - url, - Object.assign(msg.options, { onClick: "showTable" }), - (catalog) => { - aladin.addCatalog(catalog); - }, - false, - ); - URL.revokeObjectURL(url); - break; - } - }); + const eventHandler = new EventHandler(aladin, aladinDiv, model); + eventHandler.subscribeAll(); return () => { - // need to unsubscribe the listeners - model.off("change:shared_target"); - model.off("change:shared_fov"); - model.off("change:height"); - model.off("change:coo_frame"); - model.off("change:survey"); - model.off("change:overlay_survey"); - model.off("change:overlay_survey_opacity"); - model.off("change:trigger_event"); - model.off("change:_table"); - model.off("msg:custom"); - - aladin.off("positionChanged"); - aladin.off("zoomChanged"); - aladin.off("objectHovered"); - aladin.off("objectClicked"); - aladin.off("click"); - aladin.off("select"); + // Need to unsubscribe the listeners + eventHandler.unsubscribeAll(); }; } diff --git a/pyproject.toml b/pyproject.toml index 2359e992..0c301e02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,12 @@ name = "ipyaladin" dynamic = ["version"] dependencies = ["anywidget", "astropy"] readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.8" [project.optional-dependencies] -dev = ["watchfiles", "jupyterlab", "ruff"] +dev = ["pytest", "watchfiles", "jupyterlab", "ruff"] +recommended = ["mocpy"] # automatically add the dev feature to the default env (e.g., hatch shell) [tool.hatch.envs.default] @@ -40,9 +43,13 @@ extend-include = ["*.ipynb"] [tool.ruff.lint] extend-select = ["E", "W", "YTT", "ASYNC", "BLE", "B", "A", "C4", "ISC", "PIE", "PYI", "RSE", "RET", "SIM", - "PTH", "TD", "ERA", "PL", "PERF", "RUF", "ARG" + "PTH", "TD", "ERA", "PL", "PERF", "RUF", "ARG", + "ANN", "D" ] -ignore = ["ISC001"] +ignore = ["ISC001", "ANN101", "D203", "D213", "D100", "D105"] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" [tool.ruff.format] docstring-code-format = false diff --git a/src/ipyaladin/__init__.py b/src/ipyaladin/__init__.py index 96a1133b..7b80ec63 100644 --- a/src/ipyaladin/__init__.py +++ b/src/ipyaladin/__init__.py @@ -1,391 +1,11 @@ +"""Top-level package for ipyaladin.""" + import importlib.metadata -import pathlib -from typing import ClassVar, Union -import warnings -import anywidget -from astropy.coordinates import SkyCoord, Angle -from traitlets import ( - Float, - Int, - Unicode, - Bool, - List, - Dict, - Any, - Bytes, - default, - Undefined, -) +from .aladin import Aladin # noqa: F401 -from .coordinate_parser import parse_coordinate_string try: __version__ = importlib.metadata.version("ipyaladin") except importlib.metadata.PackageNotFoundError: __version__ = "unknown" - - -class Aladin(anywidget.AnyWidget): - _esm = pathlib.Path(__file__).parent / "static" / "widget.js" - _css = pathlib.Path(__file__).parent / "static" / "widget.css" - - # Options for the view initialization - height = Int(400).tag(sync=True, init_option=True) - _target = Unicode( - "0 0", - help="A private trait that stores the current target of the widget in a string." - " Its public version is the 'target' property that returns an " - "`~astropy.coordinates.SkyCoord` object", - ).tag(sync=True, init_option=True) - shared_target = Unicode( - "0 0", - help="A trait that can be used with `~ipywidgets.widgets.jslink`" - "to link two Aladin Lite widgets targets together", - ).tag(sync=True) - _fov = Float( - 60.0, - help="A private trait that stores the current field of view of the widget." - " Its public version is the 'fov' property that returns an " - "`~astropy.units.Angle` object", - ).tag(sync=True, init_option=True) - shared_fov = Float( - 60.0, - help="A trait that can be used with `~ipywidgets.widgets.jslink`" - "to link two Aladin Lite widgets field of view together", - ).tag(sync=True) - survey = Unicode("https://alaskybis.unistra.fr/DSS/DSSColor").tag( - sync=True, init_option=True - ) - coo_frame = Unicode("J2000").tag(sync=True, init_option=True) - projection = Unicode("SIN").tag(sync=True, init_option=True) - samp = Bool(False).tag(sync=True, init_option=True) - # Buttons on/off - background_color = Unicode("rgb(60, 60, 60)").tag(sync=True, init_option=True) - show_zoom_control = Bool(False).tag(sync=True, init_option=True) - show_layers_control = Bool(True).tag(sync=True, init_option=True) - show_overlay_stack_control = Bool(True).tag(sync=True, init_option=True) - show_fullscreen_control = Bool(True).tag(sync=True, init_option=True) - show_simbad_pointer_control = Bool(True).tag(sync=True, init_option=True) - show_settings_control = Bool(True).tag(sync=True, init_option=True) - show_share_control = Bool(False).tag(sync=True, init_option=True) - show_status_bar = Bool(True).tag(sync=True, init_option=True) - show_frame = Bool(True).tag(sync=True, init_option=True) - show_fov = Bool(True).tag(sync=True, init_option=True) - show_coo_location = Bool(True).tag(sync=True, init_option=True) - show_projection_control = Bool(True).tag(sync=True, init_option=True) - show_context_menu = Bool(True).tag(sync=True, init_option=True) - show_catalog = Bool(True).tag(sync=True, init_option=True) - full_screen = Bool(False).tag(sync=True, init_option=True) - # reticle - show_reticle = Bool(True).tag(sync=True, init_option=True) - reticle_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True) - reticle_size = Int(20).tag(sync=True, init_option=True) - # grid - show_coo_grid = Bool(False).tag(sync=True, init_option=True) - show_coo_grid_control = Bool(True).tag(sync=True, init_option=True) - grid_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True) - grid_opacity = Float(0.5).tag(sync=True, init_option=True) - grid_options = Dict().tag(sync=True, init_option=True) - - # content of the last click - clicked_object = Dict().tag(sync=True) - # listener callback is on the python side and contains functions to link to events - listener_callback: ClassVar = {} - - # overlay survey - overlay_survey = Unicode("").tag(sync=True, init_option=True) - overlay_survey_opacity = Float(0.0).tag(sync=True, init_option=True) - - # tables/catalogs - _table = Bytes(Undefined).tag(sync=True) - - init_options = List(trait=Any()).tag(sync=True) - - @default("init_options") - def _init_options(self): - return list(self.traits(init_option=True)) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.target = kwargs.get("target", "0 0") - self.fov = kwargs.get("fov", 60.0) - self.on_msg(self._handle_custom_message) - - def _handle_custom_message(self, model, message, list_of_buffers): # noqa: ARG002 - event_type = message["event_type"] - message_content = message["content"] - if ( - event_type == "object_clicked" - and "object_clicked" in self.listener_callback - ): - self.listener_callback["object_clicked"](message_content) - elif ( - event_type == "object_hovered" - and "object_hovered" in self.listener_callback - ): - self.listener_callback["object_hovered"](message_content) - elif event_type == "click" and "click" in self.listener_callback: - self.listener_callback["click"](message_content) - elif event_type == "select" and "select" in self.listener_callback: - self.listener_callback["select"](message_content) - - @property - def fov(self) -> Angle: - """The field of view of the Aladin Lite widget along the horizontal axis. - - It can be set with either a float in degrees - or an `~astropy.units.Angle` object. - - Returns - ------- - Angle - An astropy.units.Angle object representing the field of view. - - """ - return Angle(self._fov, unit="deg") - - @fov.setter - def fov(self, fov: Union[float, Angle]): - if isinstance(fov, Angle): - fov = fov.deg - self._fov = fov - self.send({"event_name": "change_fov", "fov": fov}) - - @property - def target(self) -> SkyCoord: - """The target of the Aladin Lite widget. - - It can be set with either a string of an `~astropy.coordinates.SkyCoord` object. - - Returns - ------- - SkyCoord - An astropy.coordinates.SkyCoord object representing the target. - - """ - ra, dec = self._target.split(" ") - return SkyCoord( - ra=ra, - dec=dec, - frame="icrs", - unit="deg", - ) - - @target.setter - def target(self, target: Union[str, SkyCoord]): - if isinstance(target, str): # If the target is str, parse it - target = parse_coordinate_string(target) - elif not isinstance(target, SkyCoord): # If the target is not str or SkyCoord - raise ValueError( - "target must be a string or an astropy.coordinates.SkyCoord object" - ) - self._target = f"{target.icrs.ra.deg} {target.icrs.dec.deg}" - self.send( - { - "event_name": "goto_ra_dec", - "ra": target.icrs.ra.deg, - "dec": target.icrs.dec.deg, - } - ) - - def add_catalog_from_URL(self, votable_URL, votable_options=None): - """Load a VOTable table from an url and load its data into the widget. - - Parameters - ---------- - votable_URL: str - votable_options: dict - - """ - if votable_options is None: - votable_options = {} - self.send( - { - "event_name": "add_catalog_from_URL", - "votable_URL": votable_URL, - "options": votable_options, - } - ) - - # MOCs - - def add_moc(self, moc, **moc_options): - """Add a MOC to the Aladin-Lite widget. - - Parameters - ---------- - moc : `~mocpy.MOC` or str or dict - The MOC can be provided as a `mocpy.MOC` object, as a string containing an - URL where the MOC can be retrieved, or as a dictionary where the keys are - the HEALPix orders and the values are the pixel indices - (ex: {"1":[1,2,4], "2":[12,13,14,21,23,25]}). - - """ - if isinstance(moc, dict): - self.send( - { - "event_name": "add_MOC_from_dict", - "moc_dict": moc, - "options": moc_options, - } - ) - elif isinstance(moc, str) and "://" in moc: - self.send( - { - "event_name": "add_MOC_from_URL", - "moc_URL": moc, - "options": moc_options, - } - ) - else: - try: - from mocpy import MOC - - if isinstance(moc, MOC): - self.send( - { - "event_name": "add_MOC_from_dict", - "moc_dict": moc.serialize("json"), - "options": moc_options, - } - ) - except ImportError as imp: - raise ValueError( - "A MOC can be given as an URL, a dictionnary, or a mocpy.MOC " - "object. To read mocpy.MOC objects, you need to install the mocpy " - "library with 'pip install mocpy'." - ) from imp - - def add_moc_from_URL(self, moc_URL, moc_options=None): - """Load a MOC from a URL and display it in Aladin Lite widget. - - Parameters - ---------- - moc_URL: str - An URL to retrieve the MOC from - moc_options: dict - - """ - warnings.warn( - "add_moc_from_URL is replaced by add_moc that detects automatically" - "that the MOC was given as an URL.", - DeprecationWarning, - stacklevel=2, - ) - if moc_options is None: - moc_options = {} - self.add_moc(moc_URL, **moc_options) - - def add_moc_from_dict(self, moc_dict, moc_options=None): - """Load a MOC from a dict object and display it in Aladin Lite widget. - - Parameters - ---------- - moc_dict: dict - It contains the MOC cells. Key are the HEALPix orders, values are the pixel - indexes, eg: {"1":[1,2,4], "2":[12,13,14,21,23,25]} - moc_options: dict - - """ - warnings.warn( - "add_moc_from_dict is replaced by add_moc that detects automatically" - "that the MOC was given as a dictionary.", - DeprecationWarning, - stacklevel=2, - ) - if moc_options is None: - moc_options = {} - self.add_moc(moc_dict, **moc_options) - - def add_table(self, table, **table_options): - """Load a table into the widget. - - Parameters - ---------- - table : astropy.table.table.QTable or astropy.table.table.Table - table that must contain coordinates information - - Examples - -------- - Cell 1: - >>> from ipyaladin import Aladin - >>> from astropy.table import QTable - >>> aladin = Aladin(fov=2, target='M1') - >>> aladin - Cell 2: - >>> ra = [83.63451584700, 83.61368056017, 83.58780251600] - >>> dec = [22.05652591227, 21.97517807639, 21.99277764451] - >>> name = ["Gaia EDR3 3403818589184411648", - "Gaia EDR3 3403817661471500416", - "Gaia EDR3 3403817936349408000", - ] - >>> table = QTable([ra, dec, name], - names=("ra", "dec", "name"), - meta={"name": "my sample table"}) - >>> aladin.add_table(table) - And the table should appear in the output of Cell 1! - - """ - # this library must be installed, and is used in votable operations - # http://www.astropy.org/ - import io - - table_bytes = io.BytesIO() - table.write(table_bytes, format="votable") - self._table = table_bytes.getvalue() - self.send({"event_name": "add_table", "options": table_options}) - - def add_overlay_from_stcs(self, stc_string, **overlay_options): - """Add an overlay layer defined by a STC-S string. - - Parameters - ---------- - stc_string: str - The STC-S string. - overlay_options: keyword arguments - - """ - self.send( - { - "event_name": "add_overlay_from_stcs", - "stc_string": stc_string, - "overlay_options": overlay_options, - } - ) - - # Note: the print() options end='\r'allow us to override the previous prints, - # thus only the last message will be displayed at the screen - - def get_JPEG_thumbnail(self): - """Create a popup window with the current Aladin view.""" - self.send({"event_name": "get_JPG_thumbnail"}) - - def set_color_map(self, color_map_name): - self.send({"event_name": "change_colormap", "colormap": color_map_name}) - - def rectangular_selection(self): - self.send({"event_name": "trigger_rectangular_selection"}) - - # Adding a listener - - def add_listener(self, listener_type, callback): - """Add a listener to the widget. - - Parameters - ---------- - listener_type: str - Can either be 'objectHovered' or 'objClicked' - callback: Callable - A python function to be called when the event corresponding to the - listener_type is detected - - """ - if listener_type in {"objectHovered", "object_hovered"}: - self.listener_callback["object_hovered"] = callback - elif listener_type in {"objectClicked", "object_clicked"}: - self.listener_callback["object_clicked"] = callback - elif listener_type == "click": - self.listener_callback["click"] = callback - elif listener_type == "select": - self.listener_callback["select"] = callback diff --git a/src/ipyaladin/aladin.py b/src/ipyaladin/aladin.py new file mode 100644 index 00000000..0386f215 --- /dev/null +++ b/src/ipyaladin/aladin.py @@ -0,0 +1,425 @@ +""" +Aladin Lite widget for Jupyter Notebook. + +This module provides a Python wrapper around the Aladin Lite JavaScript library. +It allows to display astronomical images and catalogs in an interactive way. +""" + +import pathlib +import typing +from typing import ClassVar, Union, Final, Optional +import warnings + +import anywidget +from astropy.table.table import QTable +from astropy.table import Table +from astropy.coordinates import SkyCoord, Angle +import traitlets +from traitlets import ( + Float, + Int, + Unicode, + Bool, + Any, + default, +) + +from .coordinate_parser import parse_coordinate_string + + +class Aladin(anywidget.AnyWidget): + """Aladin Lite widget. + + This widget is a Python wrapper around the Aladin Lite JavaScript library. + It allows to display astronomical images and catalogs in an interactive way. + """ + + _esm: Final = pathlib.Path(__file__).parent / "static" / "widget.js" + _css: Final = pathlib.Path(__file__).parent / "static" / "widget.css" + + # Options for the view initialization + height = Int(400).tag(sync=True, init_option=True) + _target = Unicode( + "0 0", + help="A private trait that stores the current target of the widget in a string." + " Its public version is the 'target' property that returns an " + "`~astropy.coordinates.SkyCoord` object", + ).tag(sync=True, init_option=True) + _fov = Float( + 60.0, + help="A private trait that stores the current field of view of the widget." + " Its public version is the 'fov' property that returns an " + "`~astropy.units.Angle` object", + ).tag(sync=True, init_option=True) + survey = Unicode("https://alaskybis.unistra.fr/DSS/DSSColor").tag( + sync=True, init_option=True + ) + coo_frame = Unicode("J2000").tag(sync=True, init_option=True) + projection = Unicode("SIN").tag(sync=True, init_option=True) + samp = Bool(False).tag(sync=True, init_option=True) + # Buttons on/off + background_color = Unicode("rgb(60, 60, 60)").tag(sync=True, init_option=True) + show_zoom_control = Bool(False).tag(sync=True, init_option=True) + show_layers_control = Bool(True).tag(sync=True, init_option=True) + show_overlay_stack_control = Bool(True).tag(sync=True, init_option=True) + show_fullscreen_control = Bool(True).tag(sync=True, init_option=True) + show_simbad_pointer_control = Bool(True).tag(sync=True, init_option=True) + show_settings_control = Bool(True).tag(sync=True, init_option=True) + show_share_control = Bool(False).tag(sync=True, init_option=True) + show_status_bar = Bool(True).tag(sync=True, init_option=True) + show_frame = Bool(True).tag(sync=True, init_option=True) + show_fov = Bool(True).tag(sync=True, init_option=True) + show_coo_location = Bool(True).tag(sync=True, init_option=True) + show_projection_control = Bool(True).tag(sync=True, init_option=True) + show_context_menu = Bool(True).tag(sync=True, init_option=True) + show_catalog = Bool(True).tag(sync=True, init_option=True) + full_screen = Bool(False).tag(sync=True, init_option=True) + # reticle + show_reticle = Bool(True).tag(sync=True, init_option=True) + reticle_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True) + reticle_size = Int(20).tag(sync=True, init_option=True) + # grid + show_coo_grid = Bool(False).tag(sync=True, init_option=True) + show_coo_grid_control = Bool(True).tag(sync=True, init_option=True) + grid_color = Unicode("rgb(178, 50, 178)").tag(sync=True, init_option=True) + grid_opacity = Float(0.5).tag(sync=True, init_option=True) + grid_options = traitlets.Dict().tag(sync=True, init_option=True) + + # content of the last click + clicked_object = traitlets.Dict().tag(sync=True) + # listener callback is on the python side and contains functions to link to events + listener_callback: ClassVar[typing.Dict[str, callable]] = {} + + # overlay survey + overlay_survey = Unicode("").tag(sync=True, init_option=True) + overlay_survey_opacity = Float(0.0).tag(sync=True, init_option=True) + + init_options = traitlets.List(trait=Any()).tag(sync=True) + + @default("init_options") + def _init_options(self) -> typing.List[str]: + return list(self.traits(init_option=True)) + + def __init__(self, *args: any, **kwargs: any) -> None: + super().__init__(*args, **kwargs) + self.target = kwargs.get("target", "0 0") + self.fov = kwargs.get("fov", 60.0) + self.on_msg(self._handle_custom_message) + + def _handle_custom_message(self, _: any, message: dict, __: any) -> None: + event_type = message["event_type"] + message_content = message["content"] + if ( + event_type == "object_clicked" + and "object_clicked" in self.listener_callback + ): + self.listener_callback["object_clicked"](message_content) + elif ( + event_type == "object_hovered" + and "object_hovered" in self.listener_callback + ): + self.listener_callback["object_hovered"](message_content) + elif event_type == "click" and "click" in self.listener_callback: + self.listener_callback["click"](message_content) + elif event_type == "select" and "select" in self.listener_callback: + self.listener_callback["select"](message_content) + + @property + def fov(self) -> Angle: + """The field of view of the Aladin Lite widget along the horizontal axis. + + It can be set with either a float in degrees + or an `~astropy.units.Angle` object. + + Returns + ------- + Angle + An astropy.units.Angle object representing the field of view. + + """ + return Angle(self._fov, unit="deg") + + @fov.setter + def fov(self, fov: Union[float, Angle]) -> None: + if isinstance(fov, Angle): + fov = fov.deg + self._fov = fov + self.send({"event_name": "change_fov", "fov": fov}) + + @property + def target(self) -> SkyCoord: + """The target of the Aladin Lite widget. + + It can be set with either a string or an `~astropy.coordinates.SkyCoord` object. + + Returns + ------- + SkyCoord + An astropy.coordinates.SkyCoord object representing the target. + + """ + ra, dec = self._target.split(" ") + return SkyCoord( + ra=ra, + dec=dec, + frame="icrs", + unit="deg", + ) + + @target.setter + def target(self, target: Union[str, SkyCoord]) -> None: + if isinstance(target, str): # If the target is str, parse it + target = parse_coordinate_string(target) + elif not isinstance(target, SkyCoord): # If the target is not str or SkyCoord + raise ValueError( + "target must be a string or an astropy.coordinates.SkyCoord object" + ) + self._target = f"{target.icrs.ra.deg} {target.icrs.dec.deg}" + self.send( + { + "event_name": "goto_ra_dec", + "ra": target.icrs.ra.deg, + "dec": target.icrs.dec.deg, + } + ) + + def add_catalog_from_URL( + self, votable_URL: str, votable_options: Optional[dict] = None + ) -> None: + """Load a VOTable table from an url and load its data into the widget. + + Parameters + ---------- + votable_URL: str + votable_options: dict + + """ + if votable_options is None: + votable_options = {} + self.send( + { + "event_name": "add_catalog_from_URL", + "votable_URL": votable_URL, + "options": votable_options, + } + ) + + # MOCs + + def add_moc(self, moc: any, **moc_options: any) -> None: + """Add a MOC to the Aladin-Lite widget. + + Parameters + ---------- + moc : `~mocpy.MOC` or str or dict + The MOC can be provided as a `mocpy.MOC` object, as a string containing an + URL where the MOC can be retrieved, or as a dictionary where the keys are + the HEALPix orders and the values are the pixel indices + (ex: {"1":[1,2,4], "2":[12,13,14,21,23,25]}). + + """ + if isinstance(moc, dict): + self.send( + { + "event_name": "add_MOC_from_dict", + "moc_dict": moc, + "options": moc_options, + } + ) + elif isinstance(moc, str) and "://" in moc: + self.send( + { + "event_name": "add_MOC_from_URL", + "moc_URL": moc, + "options": moc_options, + } + ) + else: + try: + from mocpy import MOC + + if isinstance(moc, MOC): + self.send( + { + "event_name": "add_MOC_from_dict", + "moc_dict": moc.serialize("json"), + "options": moc_options, + } + ) + except ImportError as imp: + raise ValueError( + "A MOC can be given as an URL, a dictionnary, or a mocpy.MOC " + "object. To read mocpy.MOC objects, you need to install the mocpy " + "library with 'pip install mocpy'." + ) from imp + + def add_moc_from_URL( + self, moc_URL: str, moc_options: Optional[dict] = None + ) -> None: + """Load a MOC from a URL and display it in Aladin Lite widget. + + Parameters + ---------- + moc_URL: str + An URL to retrieve the MOC from + moc_options: dict + + """ + warnings.warn( + "add_moc_from_URL is replaced by add_moc that detects automatically" + "that the MOC was given as an URL.", + DeprecationWarning, + stacklevel=2, + ) + if moc_options is None: + moc_options = {} + self.add_moc(moc_URL, **moc_options) + + def add_moc_from_dict( + self, moc_dict: dict, moc_options: Optional[dict] = None + ) -> None: + """Load a MOC from a dict object and display it in Aladin Lite widget. + + Parameters + ---------- + moc_dict: dict + It contains the MOC cells. Key are the HEALPix orders, values are the pixel + indexes, eg: {"1":[1,2,4], "2":[12,13,14,21,23,25]} + moc_options: dict + + """ + warnings.warn( + "add_moc_from_dict is replaced by add_moc that detects automatically" + "that the MOC was given as a dictionary.", + DeprecationWarning, + stacklevel=2, + ) + if moc_options is None: + moc_options = {} + self.add_moc(moc_dict, **moc_options) + + def add_table(self, table: Union[QTable, Table], **table_options: any) -> None: + """Load a table into the widget. + + Parameters + ---------- + table : astropy.table.table.QTable or astropy.table.table.Table + table that must contain coordinates information + + Examples + -------- + Cell 1: + >>> from ipyaladin import Aladin + >>> from astropy.table import QTable + >>> aladin = Aladin(fov=2, target='M1') + >>> aladin + Cell 2: + >>> ra = [83.63451584700, 83.61368056017, 83.58780251600] + >>> dec = [22.05652591227, 21.97517807639, 21.99277764451] + >>> name = ["Gaia EDR3 3403818589184411648", + "Gaia EDR3 3403817661471500416", + "Gaia EDR3 3403817936349408000", + ] + >>> table = QTable([ra, dec, name], + names=("ra", "dec", "name"), + meta={"name": "my sample table"}) + >>> aladin.add_table(table) + And the table should appear in the output of Cell 1! + + """ + import io + + table_bytes = io.BytesIO() + table.write(table_bytes, format="votable") + self.send( + {"event_name": "add_table", "options": table_options}, + buffers=[table_bytes.getvalue()], + ) + + def add_overlay_from_stcs(self, stc_string: str, **overlay_options: any) -> None: + """Add an overlay layer defined by a STC-S string. + + Parameters + ---------- + stc_string: str + The STC-S string. + overlay_options: keyword arguments + + """ + self.send( + { + "event_name": "add_overlay_from_stcs", + "stc_string": stc_string, + "overlay_options": overlay_options, + } + ) + + def get_JPEG_thumbnail(self) -> None: + """Create a popup window with the current Aladin view.""" + self.send({"event_name": "get_JPG_thumbnail"}) + + def set_color_map(self, color_map_name: str) -> None: + """Change the color map of the Aladin Lite widget. + + Parameters + ---------- + color_map_name: str + The name of the color map to use. + + """ + self.send({"event_name": "change_colormap", "colormap": color_map_name}) + + def rectangular_selection(self) -> None: + """Trigger the rectangular selection tool.""" + self.send({"event_name": "trigger_rectangular_selection"}) + + # Adding a listener + + def set_listener(self, listener_type: str, callback: callable) -> None: + """Set a listener for an event to the widget. + + Parameters + ---------- + listener_type: str + Can either be 'object_hovered', 'object_clicked', 'click' or 'select' + callback: callable + A python function to be called when the event corresponding to the + listener_type is detected + + """ + if listener_type in {"objectHovered", "object_hovered"}: + self.listener_callback["object_hovered"] = callback + elif listener_type in {"objectClicked", "object_clicked"}: + self.listener_callback["object_clicked"] = callback + elif listener_type == "click": + self.listener_callback["click"] = callback + elif listener_type == "select": + self.listener_callback["select"] = callback + else: + raise ValueError( + "listener_type must be 'object_hovered', " + "'object_clicked', 'click' or 'select'" + ) + + def add_listener(self, listener_type: str, callback: callable) -> None: + """Add a listener to the widget. Use set_listener instead. + + Parameters + ---------- + listener_type: str + Can either be 'object_hovered', 'object_clicked', 'click' or 'select' + callback: callable + A python function to be called when the event corresponding to the + listener_type is detected + + Note + ---- + This method is deprecated, use set_listener instead + + """ + warnings.warn( + "add_listener is deprecated, use set_listener instead", + DeprecationWarning, + stacklevel=2, + ) + self.set_listener(listener_type, callback) diff --git a/src/ipyaladin/coordinate_parser.py b/src/ipyaladin/coordinate_parser.py index 5dd3dc2a..a23e1ce3 100644 --- a/src/ipyaladin/coordinate_parser.py +++ b/src/ipyaladin/coordinate_parser.py @@ -1,3 +1,5 @@ +from typing import Tuple + from astropy.coordinates import SkyCoord, Angle import re @@ -18,7 +20,7 @@ def parse_coordinate_string(string: str) -> SkyCoord: """ if not _is_coordinate_string(string): return SkyCoord.from_name(string) - coordinates: tuple[str, str] = _split_coordinate_string(string) + coordinates: Tuple[str, str] = _split_coordinate_string(string) # Parse ra and dec to astropy Angle objects dec: Angle = Angle(coordinates[1], unit="deg") if _is_hour_angle_string(coordinates[0]): @@ -51,7 +53,7 @@ def _is_coordinate_string(string: str) -> bool: return bool(re.match(regex, string)) -def _split_coordinate_string(coo: str) -> tuple[str, str]: +def _split_coordinate_string(coo: str) -> Tuple[str, str]: """Split a string containing coordinates in two parts. Parameters diff --git a/src/test/test_aladin.py b/src/test/test_aladin.py index e7eba792..46edd038 100644 --- a/src/test/test_aladin.py +++ b/src/test/test_aladin.py @@ -1,7 +1,8 @@ import pytest from astropy.coordinates import Angle -from ipyaladin import Aladin, parse_coordinate_string +from ipyaladin import Aladin +from ipyaladin.coordinate_parser import parse_coordinate_string test_aladin_string_target = [ "M 31", @@ -34,7 +35,7 @@ @pytest.mark.parametrize("target", test_aladin_string_target) -def test_aladin_string_target_set(target): +def test_aladin_string_target_set(target: str) -> None: """Test setting the target of an Aladin object with a string or a SkyCoord object. Parameters @@ -51,7 +52,7 @@ def test_aladin_string_target_set(target): @pytest.mark.parametrize("target", test_aladin_string_target) -def test_aladin_sky_coord_target_set(target): +def test_aladin_sky_coord_target_set(target: str) -> None: """Test setting and getting the target of an Aladin object with a SkyCoord object. Parameters @@ -77,7 +78,7 @@ def test_aladin_sky_coord_target_set(target): @pytest.mark.parametrize("angle", test_aladin_float_fov) -def test_aladin_float_fov_set(angle): +def test_aladin_float_fov_set(angle: float) -> None: """Test setting the angle of an Aladin object with a float. Parameters @@ -92,7 +93,7 @@ def test_aladin_float_fov_set(angle): @pytest.mark.parametrize("angle", test_aladin_float_fov) -def test_aladin_angle_fov_set(angle): +def test_aladin_angle_fov_set(angle: float) -> None: """Test setting the angle of an Aladin object with an Angle object. Parameters diff --git a/src/test/test_coordinate_parser.py b/src/test/test_coordinate_parser.py index e0cd3ff6..b399bc4a 100644 --- a/src/test/test_coordinate_parser.py +++ b/src/test/test_coordinate_parser.py @@ -1,3 +1,4 @@ +from typing import Tuple from ipyaladin.coordinate_parser import ( parse_coordinate_string, _split_coordinate_string, @@ -32,7 +33,7 @@ @pytest.mark.parametrize(("inp", "expected"), test_is_coordinate_string_values) -def test_is_coordinate_string(inp, expected): +def test_is_coordinate_string(inp: str, expected: bool) -> None: """Test the function _is_coordinate_string. Parameters @@ -67,7 +68,7 @@ def test_is_coordinate_string(inp, expected): @pytest.mark.parametrize(("inp", "expected"), test_split_coordinate_string_values) -def test_split_coordinate_string(inp, expected): +def test_split_coordinate_string(inp: str, expected: Tuple[str, str]) -> None: """Test the function _split_coordinate_string. Parameters @@ -91,7 +92,7 @@ def test_split_coordinate_string(inp, expected): @pytest.mark.parametrize(("inp", "expected"), test_is_hour_angle_string_values) -def test_is_hour_angle_string(inp, expected): +def test_is_hour_angle_string(inp: str, expected: bool) -> None: """Test the function _is_hour_angle_string. Parameters @@ -175,7 +176,7 @@ def test_is_hour_angle_string(inp, expected): @pytest.mark.parametrize(("inp", "expected"), test_parse_coordinate_string_values) -def test_parse_coordinate_string(inp, expected): +def test_parse_coordinate_string(inp: str, expected: SkyCoord) -> None: """Test the function parse_coordinate_string. Parameters
MAIN_IDRADEC