diff --git a/.gitignore b/.gitignore index b64e4ead..c2c37278 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ conda-recipe/ipyaladin # Documentation docs/_build/ -docs/_collections/ \ No newline at end of file +docs/_collections/ + +# Examples +examples/*.png \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ec4250..32ae3aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New way to make a selection on the view with `selection` method (#100) - Add selected sources export as `astropy.Table` list with property `selected_objects` (#100) - Add function `get_view_as_fits` to export the view as a `astropy.io.fits.HDUList` (#86) +- Add function `save_view_as_image` to save the view as an image file (#108) ### Deprecated diff --git a/examples/03_Functions.ipynb b/examples/03_Functions.ipynb index f551470b..3764790c 100644 --- a/examples/03_Functions.ipynb +++ b/examples/03_Functions.ipynb @@ -140,12 +140,10 @@ ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "help(aladin_bis.get_JPEG_thumbnail)" + "Save the view as an image" ] }, { @@ -154,42 +152,11 @@ "metadata": {}, "outputs": [], "source": [ - "aladin_bis.get_JPEG_thumbnail()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Check that your browser didn't block the popup window if you don't see the thumbnail. This will not work in VSCode or other notebooks editors that are not working in a browser." + "aladin_bis.save_view_as_image(\"4Sgr.png\")" ] } ], - "metadata": { - "kernelspec": { - "display_name": "base", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.8" - }, - "vscode": { - "interpreter": { - "hash": "85bb43f988bdbdc027a50b6d744a62eda8a76617af1f4f9b115d38242716dbac" - } - } - }, + "metadata": {}, "nbformat": 4, "nbformat_minor": 4 } diff --git a/examples/11_Extracting_information_from_the_view.ipynb b/examples/11_Extracting_information_from_the_view.ipynb index 7efaadab..55b3ff6d 100644 --- a/examples/11_Extracting_information_from_the_view.ipynb +++ b/examples/11_Extracting_information_from_the_view.ipynb @@ -66,7 +66,7 @@ "id": "998def1f-3963-405b-8be2-6d4ef4012634", "metadata": {}, "source": [ - "If you edit the view either by modifiing the widget through its interface, or programmatically: " + "If you edit the view either by modifiing the widget through its interface, or programmatically:" ] }, { @@ -106,7 +106,7 @@ "id": "f5add3a2-be30-488e-86df-426338b98f5d", "metadata": {}, "source": [ - "If you try to recover the value in the **same cell**, you'll get a `WidgetCommunicationError` error. This is because the calculation of the WCS is done by Aladin Lite *between* cell executions. \n", + "If you try to recover the value in the **same cell**, you'll get a `WidgetCommunicationError` error. This is because the calculation of the WCS is done by Aladin Lite *between* cell executions.\n", "\n", "## Getting the field of view\n", "\n", @@ -204,7 +204,7 @@ "metadata": {}, "source": [ "## Getting the view as a fits file\n", - "The following method allow you to retrieve the current view as a fits file. If a `path` is given as a second argument, the fits file will be saved." + "The following method allow you to retrieve the current view as a fits file." ] }, { @@ -247,6 +247,26 @@ "plt.subplot(projection=wcs)\n", "plt.imshow(fits[0].data, cmap=\"binary_r\", norm=\"asinh\", vmin=0.001)" ] + }, + { + "cell_type": "markdown", + "id": "c64190a2757b707", + "metadata": {}, + "source": [ + "## Saving the view as an image file\n", + "\n", + "In `save_view_as_image`, the first argument is the path to the file, the second is the format (\"png\", \"jpeg\", \"webp\"), and the third is a boolean to indicate if you want to include the Aladin Lite logo in the image." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85f40dc5e6af3ae1", + "metadata": {}, + "outputs": [], + "source": [ + "aladin.save_view_as_image(path=\"./crab.png\", image_format=\"png\", with_logo=True)" + ] } ], "metadata": { diff --git a/js/models/event_handler.js b/js/models/event_handler.js index 1b095cab..e98c231e 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -261,6 +261,7 @@ export default class EventHandler { this.eventHandlers = { change_fov: this.messageHandler.handleChangeFoV, goto_ra_dec: this.messageHandler.handleGotoRaDec, + save_view_as_image: this.messageHandler.handleSaveViewAsImage, add_fits: this.messageHandler.handleAddFits, add_catalog_from_URL: this.messageHandler.handleAddCatalogFromURL, add_MOC_from_URL: this.messageHandler.handleAddMOCFromURL, diff --git a/js/models/message_handler.js b/js/models/message_handler.js index bfc668ef..b3def3a5 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -17,6 +17,25 @@ export default class MessageHandler { this.aladin.gotoRaDec(msg["ra"], msg["dec"]); } + async handleSaveViewAsImage(msg) { + const path = msg["path"]; + const format = msg["format"]; + const withLogo = msg["with_logo"]; + const buffer = await this.aladin.getViewData( + "arraybuffer", + `image/${format}`, + withLogo, + ); + this.model.send( + { + event_type: "save_view_as_image", + path: path, + }, + null, + [buffer], + ); + } + handleAddFits(msg, buffers) { const options = convertOptionNamesToCamelCase(msg["options"] || {}); if (!options.name) diff --git a/src/ipyaladin/widget.py b/src/ipyaladin/widget.py index af4f54a1..2d066811 100644 --- a/src/ipyaladin/widget.py +++ b/src/ipyaladin/widget.py @@ -162,7 +162,7 @@ class Aladin(anywidget.AnyWidget): ) # reticle show_reticle = Bool( - True, help="Wether to show the reticle in the middle of the view." + True, help="Whether to show the reticle in the middle of the view." ).tag(sync=True, init_option=True, only_init=True) reticle_color = Unicode("rgb(178, 50, 178)", help="The color of the reticle.").tag( sync=True, init_option=True, only_init=True @@ -175,7 +175,7 @@ class Aladin(anywidget.AnyWidget): False, help="Whether the coordinates grid should be shown at startup." ).tag(sync=True, init_option=True, only_init=True) show_coo_grid_control = Bool( - True, help="Whether to show the coordinate grid control toolbar. " + True, help="Whether to show the coordinate grid control toolbar." ).tag(sync=True, init_option=True, only_init=True) grid_color = Unicode( "rgb(178, 50, 178)", @@ -208,7 +208,7 @@ class Aladin(anywidget.AnyWidget): overlay_survey_opacity = Float(0.0).tag(sync=True, init_option=True) _base_layer_last_view = Unicode( survey.default_value, - help="The last view of the base layer. It is used " + help="A private trait for the base layer of the last view. It is useful " "to convert the view to an astropy.HDUList", ).tag(sync=True) @@ -225,23 +225,24 @@ def __init__(self, *args: any, **kwargs: any) -> None: self.fov = kwargs.get("fov", 60.0) self.on_msg(self._handle_custom_message) - def _handle_custom_message(self, _: any, message: dict, __: any) -> None: + def _handle_custom_message(self, _: any, message: dict, buffers: 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) + 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) + self.listener_callback["object_hovered"](message["content"]) elif event_type == "click" and "click" in self.listener_callback: - self.listener_callback["click"](message_content) + self.listener_callback["click"](message["content"]) elif event_type == "select" and "select" in self.listener_callback: - self.listener_callback["select"](message_content) + self.listener_callback["select"](message["content"]) + elif event_type == "save_view_as_image": + self._save_file(message["path"], buffers[0]) @property def selected_objects(self) -> List[Table]: @@ -291,7 +292,8 @@ def wcs(self) -> WCS: """ if self._wcs == {}: raise WidgetCommunicationError( - "The world coordinate system is not available. " + "The world coordinate system is not available. This often happens when " + "the WCS is modified and read in the same cell. " "Please recover it from another cell." ) if "RADECSYS" in self._wcs: # RADECSYS keyword is deprecated for astropy.WCS @@ -310,7 +312,8 @@ def fov_xy(self) -> Tuple[Angle, Angle]: """ if self._fov_xy == {}: raise WidgetCommunicationError( - "The field of view along the two axes is not available. " + "The field of view along the two axes is not available. This often " + "happens when the FOV is modified and read in the same cell. " "Please recover it from another cell." ) return ( @@ -330,6 +333,10 @@ def fov(self) -> Angle: astropy.coordinates.Angle An astropy.coordinates.Angle object representing the field of view. + See Also + -------- + fov_xy + """ return Angle(self._fov, unit="deg") @@ -382,6 +389,50 @@ def target(self, target: Union[str, SkyCoord]) -> None: } ) + def _save_file(self, path: str, buffer: bytes) -> None: + """Save a file from a buffer. + + Parameters + ---------- + path : str + The path where the file will be saved. + buffer : bytes + The buffer containing the file. + + """ + with Path(path).open("wb") as file: + file.write(buffer) + + def save_view_as_image( + self, path: Union[str, Path], image_format: str = "png", with_logo: bool = True + ) -> None: + """Save the current view of the widget as an image file. + + Parameters + ---------- + path : Union[str, Path] + The path where the image will be saved. + image_format : str + The format of the image. Can be 'png', 'jpeg' or 'webp'. + with_logo : bool + Whether to include the Aladin Lite logo in the image. + + See Also + -------- + get_view_as_fits + + """ + if image_format not in {"png", "jpeg", "webp"}: + raise ValueError("image_format must be 'png', 'jpeg' or 'webp") + self.send( + { + "event_name": "save_view_as_image", + "path": str(path), + "format": image_format, + "with_logo": with_logo, + } + ) + def get_view_as_fits(self) -> HDUList: """Get the base layer of the widget as an astropy HDUList object. @@ -394,6 +445,10 @@ def get_view_as_fits(self) -> HDUList: astropy.io.fits.HDUList The FITS object containing the image. + See Also + -------- + save_view_as_image + """ try: from astroquery.hips2fits import hips2fits @@ -415,6 +470,19 @@ def get_view_as_fits(self) -> HDUList: ) from e return fits + def get_JPEG_thumbnail(self) -> None: + """Create a new tab with the current Aladin view. + + This method will only work if you are running a notebook in a browser (for + example, it won't do anything in VSCode). + + See Also + -------- + save_view_as_image: will save the image on disk instead + + """ + self.send({"event_name": "get_JPG_thumbnail"}) + def add_catalog_from_URL( self, votable_URL: str, votable_options: Optional[dict] = None ) -> None: @@ -726,14 +794,6 @@ def add_graphic_overlay_from_stcs( } ) - def get_JPEG_thumbnail(self) -> None: - """Create a popup window with the current Aladin view. - - This method will only work if you are running a notebook in a browser (for - example, it won't do anything in VSCode). - """ - 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.