diff --git a/examples/13_Programatic_Region_Selection.ipynb b/examples/13_Programatic_Region_Selection.ipynb new file mode 100644 index 00000000..519d7ca9 --- /dev/null +++ b/examples/13_Programatic_Region_Selection.ipynb @@ -0,0 +1,171 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fb39c230", + "metadata": {}, + "source": [ + "# Programatic Region Selection\n", + "This notebook shows how you can select all of the sources within an astropy region as a selection, export the selection region, and draw a graphic overlay of the exported region." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72180ae5-f9ec-4355-9497-200439da187b", + "metadata": {}, + "outputs": [], + "source": [ + "from ipyaladin import Aladin\n", + "\n", + "from astropy.coordinates import SkyCoord\n", + "from astroquery.mast import Catalogs\n", + "\n", + "from regions import CircleSkyRegion\n", + "from astropy import units as u" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43c0c170", + "metadata": {}, + "outputs": [], + "source": [ + "aladin = Aladin(\n", + " show_coo_grid=True,\n", + " target=\"TRAPPIST-1\",\n", + " coo_frame=\"icrs\",\n", + " fov=0.05,\n", + " height=400,\n", + " samp=True,\n", + ")\n", + "aladin" + ] + }, + { + "cell_type": "markdown", + "id": "30b5ab5a", + "metadata": {}, + "source": [ + "### Extract region selections\n", + "First, load a custom catalog that will provide the sources to select from" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1dc08de", + "metadata": {}, + "outputs": [], + "source": [ + "target_name = \"TRAPPIST-1\"\n", + "catalog_data = Catalogs.query_region(\n", + " coordinates=SkyCoord.from_name(target_name),\n", + " radius=0.01, # [deg]\n", + " catalog=\"Panstarrs\",\n", + ")\n", + "catalog_data.rename_columns([\"raMean\", \"decMean\"], [\"ra\", \"dec\"])\n", + "\n", + "aladin.add_table(\n", + " catalog_data, name=\"test-table\", color=\"lime\", shape=\"circle\", source_size=10\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "018d9426", + "metadata": {}, + "source": [ + "## Import a selection region\n", + "We then define a region to select within the aladin viewer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f290b822", + "metadata": {}, + "outputs": [], + "source": [ + "selection_region = CircleSkyRegion(SkyCoord.from_name(target_name), radius=0.01 * u.deg)\n", + "aladin.select_region(selection_region)" + ] + }, + { + "cell_type": "markdown", + "id": "5e1450cb-4d6e-440f-b72c-97d8a20127a6", + "metadata": {}, + "source": [ + "Note: Aladin also supports selections from `RectangleSkyRegions` and `PolygonSkyRegions`" + ] + }, + { + "cell_type": "markdown", + "id": "bc529c0a", + "metadata": {}, + "source": [ + "## Export a selection region\n", + "The list of selection made in the aladin viewer is made available through a property on the aladin instance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89624109", + "metadata": {}, + "outputs": [], + "source": [ + "aladin.selected_regions" + ] + }, + { + "cell_type": "markdown", + "id": "4c5f9ed8", + "metadata": {}, + "source": [ + "### Display the selected regions as graphic overlays \n", + "You can then display those selected regions as graphic overlays" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a95e51c7", + "metadata": {}, + "outputs": [], + "source": [ + "aladin.add_graphic_overlay_from_region(aladin.selected_regions)" + ] + }, + { + "cell_type": "markdown", + "id": "267eec63-a782-4ecd-b944-5be61e484cb1", + "metadata": {}, + "source": [ + "The list of graphic overlays drawn is available through a property on the aladin instance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43b6c363-7d6d-49ac-99b9-86b5270ea41a", + "metadata": {}, + "outputs": [], + "source": [ + "aladin.graphic_overlays" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab82cfb5-cc72-402a-b7e0-5844d49b82a4", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/js/models/event_handler.js b/js/models/event_handler.js index 935cf562..0787b020 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -1,5 +1,6 @@ import MessageHandler from "./message_handler"; import { divNumber, setDivNumber, Lock, setDivHeight } from "../utils"; +import { SelectorToJson } from "./selection_handler"; export default class EventHandler { /** @@ -244,6 +245,12 @@ export default class EventHandler { event_type: "select", content: objectsData, }); + + this.model.set("_selected_regions", [ + ...(this.model.get("_selected_regions") ?? []), + SelectorToJson(this.aladin), + ]); + this.model.save_changes(); }); /* Aladin functionalities */ @@ -279,6 +286,7 @@ export default class EventHandler { change_colormap: this.messageHandler.handleChangeColormap, get_JPG_thumbnail: this.messageHandler.handleGetJPGThumbnail, trigger_selection: this.messageHandler.handleTriggerSelection, + trigger_select_region: this.messageHandler.handleTriggerSelectRegion, add_table: this.messageHandler.handleAddTable, }; diff --git a/js/models/message_handler.js b/js/models/message_handler.js index 97f30e45..43bb8636 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -1,4 +1,5 @@ import { convertOptionNamesToCamelCase } from "../utils"; +import { SelectRegion } from "./selection_handler"; import A from "../aladin_lite"; let imageCount = 0; @@ -155,6 +156,10 @@ export default class MessageHandler { this.aladin.select(selectionType); } + handleTriggerSelectRegion(msg) { + SelectRegion(msg, this.aladin); + } + handleAddTable(msg, buffers) { const options = convertOptionNamesToCamelCase(msg["options"] || {}); const buffer = buffers[0].buffer; diff --git a/js/models/selection_handler.js b/js/models/selection_handler.js new file mode 100644 index 00000000..f99690c6 --- /dev/null +++ b/js/models/selection_handler.js @@ -0,0 +1,198 @@ +/** + * Converts the current aladin selector object to a json representation of the + * selector with coordinates in world coordinates. + * @param aladin - The aladin-lite instance + * @returns A json representation of the selector object w + */ +export function SelectorToJson(aladin) { + let selector = aladin.view.selector.select; + switch (selector.constructor.name) { + case "lg": + return CircleSelectorToJson(selector, aladin); + case "rg": + return RectangleSelectorToJson(selector, aladin); + case "Sg": + return PolySelectorToJson(selector, aladin); + default: + return; + } +} + +/** + * Converts a CircleSelector object to a json representation of the + * selector with coordinates in world coordinates. + * @param selector - CircleSelect object + * @param aladin - The aladin-lite instance + * @returns A json representation of a CircleSelect object + */ +function CircleSelectorToJson(selector, aladin) { + let startCooWorld = aladin.pix2world( + selector.startCoo.x, + selector.startCoo.y, + 0, + ); + + let radius = aladin.angularDist( + selector.startCoo.x, + selector.startCoo.y, + selector.coo.x, + selector.coo.y, + ); + + return { + type: "circle", + startCoo: { + ra: startCooWorld[0], + dec: startCooWorld[1], + }, + radius: radius, + }; +} + +/** + * Converts a RectSelector object to a json representation of the + * selector with coordinates in world coordinates. + * @param selector - RectSelect object + * @param aladin - The aladin-lite instance + * @returns A json representation of a CircleSelect object + */ +function RectangleSelectorToJson(selector, aladin) { + let coos = [ + [ + Math.min(selector.startCoo.x, selector.coo.x), + Math.min(selector.startCoo.y, selector.coo.y), + ], + [ + Math.max(selector.startCoo.x, selector.coo.x), + Math.min(selector.startCoo.y, selector.coo.y), + ], + [ + Math.max(selector.startCoo.x, selector.coo.x), + Math.max(selector.startCoo.y, selector.coo.y), + ], + [ + Math.min(selector.startCoo.x, selector.coo.x), + Math.max(selector.startCoo.y, selector.coo.y), + ], + ]; + + return { + type: "rect", + coos: coos.map((coo) => { + let cooWorld = aladin.pix2world(coo[0], coo[1], 0); + return { + ra: cooWorld[0], + dec: cooWorld[1], + }; + }), + }; +} + +/** + * Converts a PolySelector object to a json representation of the + * selector with coordinates in world coordinates. + * @param selector - PolySelect object + * @param aladin - The aladin-lite instance + * @returns A json representation of a CircleSelect object + */ +function PolySelectorToJson(selector, aladin) { + return { + type: "poly", + coos: selector.coos.map((coo) => { + let cooWorld = aladin.pix2world(coo.x, coo.y, 0); + return { + ra: cooWorld[0], + dec: cooWorld[1], + }; + }), + }; +} + +export function SelectRegion(msg, aladin) { + let selector = aladin.view.selector; + let type = msg["selection_type"]; + switch (type) { + case "circle": + SelectCircleRegion(msg, selector, aladin); + return; + case "rect": + SelectRectRegion(msg, selector, aladin); + return; + case "poly": + SelectPolyRegion(msg, selector, aladin); + return; + default: + return; + } +} + +function SelectCircleRegion(msg, selector, aladin) { + selector.setMode("circle"); + + let startCoo = msg["startCoo"]; + let endCoo = msg["endCoo"]; + + let startCooPix = aladin.world2pix(startCoo["ra"], startCoo["dec"], 0); + let endCooPix = aladin.world2pix(endCoo["ra"], endCoo["dec"], 0); + + selector.dispatch("start", {}); + selector.dispatch("mousedown", { + coo: { + x: startCooPix[0], + y: startCooPix[1], + }, + }); + selector.dispatch("mousemove", {}); + selector.dispatch("mouseup", { + coo: { + x: endCooPix[0], + y: endCooPix[1], + }, + }); +} + +function SelectRectRegion(msg, selector, aladin) { + selector.setMode("rect"); + + let startCoo = msg["startCoo"]; + let endCoo = msg["endCoo"]; + + let startCooPix = aladin.world2pix(startCoo["ra"], startCoo["dec"], 0); + let endCooPix = aladin.world2pix(endCoo["ra"], endCoo["dec"], 0); + + selector.dispatch("start", {}); + selector.dispatch("mousedown", { + coo: { + x: startCooPix[0], + y: startCooPix[1], + }, + }); + selector.dispatch("mousemove", {}); + selector.dispatch("mouseup", { + coo: { + x: endCooPix[0], + y: endCooPix[1], + }, + }); +} + +function SelectPolyRegion(msg, selector, aladin) { + selector.setMode("poly"); + + selector.dispatch("start", {}); + + let coos = msg["coos"]; + + coos.forEach((coo) => { + let cooPix = aladin.world2pix(coo["ra"], coo["dec"], 0); + selector.dispatch("click", { + coo: { + x: cooPix[0], + y: cooPix[1], + }, + }); + selector.dispatch("mousemove", {}); + }); + + selector.dispatch("finish"); +} diff --git a/src/ipyaladin/widget.py b/src/ipyaladin/widget.py index 5661d361..997a7ac7 100644 --- a/src/ipyaladin/widget.py +++ b/src/ipyaladin/widget.py @@ -9,6 +9,7 @@ import functools from json import JSONDecodeError import io +import math import pathlib from pathlib import Path import time @@ -16,6 +17,7 @@ import warnings import anywidget +from astropy import units as u from astropy.coordinates import SkyCoord, Angle, Longitude, Latitude from astropy.coordinates.name_resolve import NameResolveError from astropy.table.table import QTable @@ -74,6 +76,10 @@ Regions, ] +SupportedSelectionRegion = List[ + Union[CircleSkyRegion, RectangleSkyRegion, PolygonSkyRegion] +] + def widget_should_be_loaded(function: Callable) -> Callable: """Check if the widget is ready to execute a function. @@ -175,6 +181,10 @@ class Aladin(anywidget.AnyWidget): trait=traitlets.List(trait=traitlets.Any()), help="A list of catalogs selected by the user.", ).tag(sync=True) + _selected_regions = traitlets.List( + trait=traitlets.Dict(), + help="A list of regions selected by the user in a given session.", + ).tag(sync=True) # listener callback is on the python side and contains functions to link to events listener_callback: ClassVar[Dict[str, callable]] = {} @@ -226,6 +236,7 @@ def __init__(self, *args: any, **init_options: any) -> None: # set the traitlet self._init_options = init_options self.on_msg(self._handle_custom_message) + self._graphic_overlays = [] def _handle_custom_message(self, _: any, message: dict, buffers: any) -> None: event_type = message["event_type"] @@ -262,6 +273,73 @@ def selected_objects(self) -> List[Table]: catalogs.append(Table(objects_data)) return catalogs + @property + def selected_regions(self) -> SupportedSelectionRegion: + """The regions selected by the user in a given session. + + Returns + ------- + _______ + List[`~regions.CircleSkyRegion`, `~regions.RectangleSkyRegion`] + An astropy region object representing the region selected by the user. + + """ + if Region is None: + raise ModuleNotFoundError( + "To read regions objects, you need to install the regions library with " + "'pip install regions'." + ) + + selected_regions = [] + for region in self._selected_regions: + region_type = region.get("type", None) + + if region_type == "circle": + startCoo = region.get("startCoo", None) + radius = region.get("radius", None) + + center = SkyCoord( + startCoo["ra"], startCoo["dec"], unit="deg", frame="icrs" + ) + + selected_regions.append(CircleSkyRegion(center, radius=radius * u.deg)) + + elif region_type in ["poly", "rect"]: + coos = region.get("coos", None) + + vertices = SkyCoord( + [c["ra"] for c in coos], + [c["dec"] for c in coos], + unit="deg", + frame="icrs", + ) + selected_regions.append(PolygonSkyRegion(vertices=vertices)) + + else: + raise ValueError( + f"Unsupported region selection shape: {region_type}. \ + Supported shapes are 'circle', 'rect', or 'poly'." + ) + + return selected_regions + + @property + def graphic_overlays(self) -> SupportedRegion: + """A list of all of the graphic overlays that have been drawn. + + Returns + ------- + _______ + `~regions.CircleSkyRegion`, `~regions.EllipseSkyRegion`, + `~regions.LineSkyRegion`,`~regions.PolygonSkyRegion`, + `~regions.RectangleSkyRegion`, `~regions.Regions`, or a list of these. + The region(s) to add in Aladin Lite. It can be given as a supported region + or a list of regions from the + `regions package `_. + + """ + return self._graphic_overlays + @property def height(self) -> int: """The height of the widget. @@ -815,6 +893,9 @@ def add_graphic_overlay_from_region( else: region_list = region + # keep track of all of the graphic overlays we have drawn + self._graphic_overlays.append(region_list) + regions_infos = [] for region_element in region_list: if not isinstance(region_element, Region): @@ -928,6 +1009,88 @@ def selection(self, selection_type: str = "rectangle") -> None: raise ValueError("selection_type must be 'circle' or 'rectangle'") self.send({"event_name": "trigger_selection", "selection_type": selection_type}) + def select_region(self, region: SupportedSelectionRegion) -> None: + """Triggers Aladin Lite to select a given astropy region. + + Parameters + ---------- + __________ + region: `~regions.CircleSkyRegion`, `~regions.PolygonSkyRegion`, or a + `~regions.RectangleSkyRegion` + The selection region to add in Aladin Lite. It can be given as a supported + region from the `regions package `_. + """ + if Region is None: + raise ModuleNotFoundError( + "To read regions objects, you need to install the regions library with " + "'pip install regions'." + ) + + event_name = "trigger_select_region" + if type(region) is CircleSkyRegion: + ra = region.center.ra.value + dec = region.center.dec.value + radius = region.radius.value + self.send( + { + "event_name": event_name, + "selection_type": "circle", + "startCoo": { + "ra": ra, + "dec": dec, + }, + "endCoo": {"ra": ra + radius, "dec": dec}, + } + ) + + elif type(region) is RectangleSkyRegion: + ra = region.center.ra.value + dec = region.center.dec.value + angle = region.angle.value + width = region.width.value + height = region.height.value + + # Calculate the corners of the rectangle selection region from the provided + # ra, dec, angle, width, and height + # https://stackoverflow.com/questions/41898990/find-corners-of-a-rotated-rectangle-given-its-center-point-and-rotation # noqa: E501 + self.send( + { + "event_name": event_name, + "selection_type": "rect", + "startCoo": { + "ra": ra + + ((width / 2) * math.cos(angle)) + - ((height / 2) * math.sin(angle)), + "dec": dec + + ((width / 2) * math.sin(angle)) + + ((height / 2) * math.cos(angle)), + }, + "endCoo": { + "ra": ra + - ((width / 2) * math.cos(angle)) + + ((height / 2) * math.sin(angle)), + "dec": dec + - ((width / 2) * math.sin(angle)) + - ((height / 2) * math.cos(angle)), + }, + } + ) + + elif type(region) is PolygonSkyRegion: + self.send( + { + "event_name": event_name, + "selection_type": "poly", + "coos": [ + { + "ra": coo.ra.value, + "dec": coo.dec.value, + } + for coo in region.vertices + ], + } + ) + def rectangular_selection(self) -> None: """Trigger the rectangular selection tool.