diff --git a/CHANGELOG.md b/CHANGELOG.md index 58efa866..437c1cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `projection` parameter can now be called to change the projection of the ipyaladin viewer [#172] +## Fixed + +- Overlays are displayed on the generated HTML static file because it is passed with + traitlets instead of being sent with events. This fixes documentation examples [#151]. + ## [0.7.0] ## Added diff --git a/examples/12_Planetary_surveys.ipynb b/examples/12_Planetary_surveys.ipynb index 2298dceb..3b640e07 100644 --- a/examples/12_Planetary_surveys.ipynb +++ b/examples/12_Planetary_surveys.ipynb @@ -49,7 +49,7 @@ "id": "35ea9256", "metadata": {}, "source": [ - "The target does not return a `SkyCoord` object anymore, since we don't represent the sky here. It is a couple of `Longitude` and `Latitude`." + "The target does not return a `SkyCoord` object anymore, since we don't represent the sky here. It is a tuple of `Longitude` and `Latitude`." ] }, { diff --git a/js/models/event_handler.js b/js/models/event_handler.js index 3b4daa11..13f19a84 100644 --- a/js/models/event_handler.js +++ b/js/models/event_handler.js @@ -1,5 +1,11 @@ import MessageHandler from "./message_handler"; -import { divNumber, setDivNumber, Lock, setDivHeight } from "../utils"; +import { + divNumber, + setDivNumber, + Lock, + setDivHeight, + isLiveSession, +} from "../utils"; export default class EventHandler { /** @@ -245,6 +251,9 @@ export default class EventHandler { this.model.save_changes(); }); + // Detect environment + const isLive = isLiveSession(this.model); + /* ----------------------- */ /* Traits listeners */ /* ----------------------- */ @@ -280,9 +289,11 @@ export default class EventHandler { }, /* Rotation control */ _rotation: (rotation) => { + if (rotation === this.aladin.getRotation()) { + return; + } // And propagate it to Aladin Lite this.aladin.setRotation(rotation); - // Update WCS and FoV only if this is the last div this.updateWCS(); this.update2AxisFoV(); @@ -323,8 +334,65 @@ export default class EventHandler { overlay.setAlpha(opacity); } }, + colormap: (colormap) => { + this.aladin.getBaseImageLayer().setColormap(colormap); + }, + projection: (projection) => { + this.aladin.setProjection(projection); + + this.updateWCS(); + this.update2AxisFoV(); + this.model.save_changes(); + }, }; + // Overlays traitlet listening + const handleOverlay = (overlay) => { + if (overlay.action === "add") { + switch (overlay.type) { + case "table": + // A table is transcripted to an ArrayBuffer (thanks to widget_serialization) + this.messageHandler.handleAddTable(overlay); + break; + case "catalog_from_url": + this.messageHandler.handleAddCatalogFromURL(overlay); + break; + case "MOC_from_url": + this.messageHandler.handleAddMOCFromURL(overlay); + break; + case "MOC_from_dict": + this.messageHandler.handleAddMOCFromDict(overlay); + break; + case "regions_stcs": + this.messageHandler.handleAddGraphicOverlay(overlay); + break; + case "regions": + this.messageHandler.handleAddGraphicOverlay(overlay); + break; + case "fits-image": + this.messageHandler.handleAddFitsImage(overlay); + break; + default: + break; + } + } else { + // TODO: remove overlay + } + }; + + if (isLive) { + // Default behavior: do not listen for overlays but _overlay_patch when ipyaladin + // is connected to a jupyter/jupyterlab session (i.e. not exported to HTML) + this.traitHandlers["_overlay_patch"] = handleOverlay; + } else { + // static HTML file export (i.e. example HTML documentation files or HTML export of a notebook) + this.traitHandlers["overlays"] = (overlays) => { + for (const overlay of overlays) { + handleOverlay(overlay); + } + }; + } + for (var trait in this.traitHandlers) { let handler = this.traitHandlers[trait]; this.model.on("change:" + trait, (_, value) => handler(value)); @@ -335,15 +403,15 @@ export default class EventHandler { this.eventHandlers = { add_marker: this.messageHandler.handleAddMarker, 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, - add_MOC_from_dict: this.messageHandler.handleAddMOCFromDict, - add_overlay: this.messageHandler.handleAddOverlay, - change_colormap: this.messageHandler.handleChangeColormap, get_JPG_thumbnail: this.messageHandler.handleGetJPGThumbnail, trigger_selection: this.messageHandler.handleTriggerSelection, - add_table: this.messageHandler.handleAddTable, + //add_fits: this.messageHandler.handleAddFits, + //add_catalog_from_URL: this.messageHandler.handleAddCatalogFromURL, + //add_MOC_from_URL: this.messageHandler.handleAddMOCFromURL, + //add_MOC_from_dict: this.messageHandler.handleAddMOCFromDict, + //add_overlay: this.messageHandler.handleAddGraphicOverlay, + //add_table: this.messageHandler.handleAddTable, + //change_colormap: this.messageHandler.handleChangeColormap, }; this.model.on("msg:custom", (msg, buffers) => { diff --git a/js/models/message_handler.js b/js/models/message_handler.js index 73efb6b7..0d4a6008 100644 --- a/js/models/message_handler.js +++ b/js/models/message_handler.js @@ -49,11 +49,11 @@ export default class MessageHandler { ); } - handleAddFits(msg, buffers) { - const options = convertOptionNamesToCamelCase(msg["options"] || {}); + handleAddFitsImage(overlay) { + const options = convertOptionNamesToCamelCase(overlay["options"] || {}); if (!options.name) options.name = `image_${String(++imageCount).padStart(3, "0")}`; - const buffer = buffers[0]; + const buffer = overlay.data.buffer; const blob = new Blob([buffer], { type: "application/octet-stream" }); const url = URL.createObjectURL(blob); const image = this.aladin.createImageFITS(url, options, (ra, dec) => { @@ -64,25 +64,25 @@ export default class MessageHandler { this.aladin.setOverlayImageLayer(image, options.name); } - handleAddCatalogFromURL(msg) { - const options = convertOptionNamesToCamelCase(msg["options"] || {}); - this.aladin.addCatalog(A.catalogFromURL(msg["votable_URL"], options)); + handleAddCatalogFromURL(catalog) { + const options = convertOptionNamesToCamelCase(catalog["options"] || {}); + this.aladin.addCatalog(A.catalogFromURL(catalog["data"], options)); } - handleAddMOCFromURL(msg) { - const options = convertOptionNamesToCamelCase(msg["options"] || {}); - this.aladin.addMOC(A.MOCFromURL(msg["moc_URL"], options)); + handleAddMOCFromURL(moc) { + const options = convertOptionNamesToCamelCase(moc["options"] || {}); + this.aladin.addMOC(A.MOCFromURL(moc["data"], options)); } - handleAddMOCFromDict(msg) { - const options = convertOptionNamesToCamelCase(msg["options"] || {}); - this.aladin.addMOC(A.MOCFromJSON(msg["moc_dict"], options)); + handleAddMOCFromDict(moc) { + const options = convertOptionNamesToCamelCase(moc["options"] || {}); + this.aladin.addMOC(A.MOCFromJSON(moc["data"], options)); } - handleAddOverlay(msg) { - const regions = msg["regions_infos"]; + handleAddGraphicOverlay(graphicOverlay) { + const regions = graphicOverlay["data"]; const graphic_options = convertOptionNamesToCamelCase( - msg["graphic_options"] || {}, + graphicOverlay["options"] || {}, ); if (!graphic_options["color"]) graphic_options["color"] = "red"; const overlay = A.graphicOverlay(graphic_options); @@ -132,10 +132,6 @@ export default class MessageHandler { } } - handleChangeColormap(msg) { - this.aladin.getBaseImageLayer().setColormap(msg["colormap"]); - } - handleGetJPGThumbnail() { this.aladin.exportAsPNG(); } @@ -147,15 +143,15 @@ export default class MessageHandler { this.aladin.select(selectionType); } - handleAddTable(msg, buffers) { - const options = convertOptionNamesToCamelCase(msg["options"] || {}); + handleAddTable(table) { + const options = convertOptionNamesToCamelCase(table["options"] || {}); const circleOptions = convertOptionNamesToCamelCase( options.circleError || {}, ); const ellipseOptions = convertOptionNamesToCamelCase( options.ellipseError || {}, ); - const buffer = buffers[0].buffer; + const buffer = table.data.buffer; const decoder = new TextDecoder("utf-8"); const blob = new Blob([decoder.decode(buffer)]); const url = URL.createObjectURL(blob); diff --git a/js/utils.js b/js/utils.js index d3de2a47..203759b7 100644 --- a/js/utils.js +++ b/js/utils.js @@ -54,6 +54,22 @@ function setDivHeight(height, div) { } } +// This is needed to know whether ipyaladin is run +// from a live session or from a static export (HTML) +// Depending on that, we will listen to specific overlay traitlets. +// See the overlays and _overlay_patch traitlets definition for more informations. +function isLiveSession(model) { + // ipywidgets v7/v8: model.comm is a Comm in live sessions, null/undefined in static HTML + if (model && model.comm && typeof model.comm.send === "function") return true; + + // Extra heuristics if needed (some frontends expose a kernel on the manager) + const mgr = model?.widget_manager; + if (mgr?.kernel) return true; // classic jupyter notebook + if (mgr?.context?.sessionContext?.session?.kernel) return true; // JupyterLab + + return false; // static export (HTML) +} + export { snakeCaseToCamelCase, convertOptionNamesToCamelCase, @@ -61,4 +77,5 @@ export { divNumber, setDivNumber, setDivHeight, + isLiveSession, }; diff --git a/js/widget.js b/js/widget.js index fd72fded..06780566 100644 --- a/js/widget.js +++ b/js/widget.js @@ -63,6 +63,10 @@ function render({ model, el }) { const wcs = { ...aladin.getViewWCS() }; model.set("_wcs", wcs); + // send colormap to python + const cmap = aladin.getBaseImageLayer().getColorCfg().colormap; + model.set("colormap", cmap); + // Tell the widget is loaded so that all stored calls waiting can be executed model.set("_is_loaded", true); model.save_changes(); diff --git a/src/ipyaladin/utils/_region_converter.py b/src/ipyaladin/utils/_region_converter.py index aa38e9eb..83fb26ad 100644 --- a/src/ipyaladin/utils/_region_converter.py +++ b/src/ipyaladin/utils/_region_converter.py @@ -4,6 +4,8 @@ from astropy.coordinates.matrix_utilities import rotation_matrix from astropy.units import Quantity +import traitlets + try: from regions import ( RectangleSkyRegion, @@ -89,7 +91,7 @@ def rectangle_to_polygon_region(region: RectangleSkyRegion) -> PolygonSkyRegion: ) -class RegionInfos: +class RegionInfos(traitlets.TraitType): """Extract information from a region. Attributes @@ -113,6 +115,27 @@ def __init__(self, region: Union[str, Region]) -> None: self.options = {} self.from_region(region) + # inherited from traitlets.TraitType + def validate(self, obj: any, value: dict) -> dict: + if not isinstance(value, dict): + self.error(obj, value) + + required_keys = {"region_type", "infos", "options"} + missing = required_keys - value.keys() + if missing: + raise traitlets.TraitError(f"Missing keys: {missing}") + + if not isinstance(value["options"], dict): + self.error(obj, value) + + if not isinstance(value["region_type"], str): + self.error(obj, value) + + if not isinstance(value["infos"], dict): + self.error(obj, value) + + return value + def from_region(self, region: Union[str, Region]) -> None: """Parse a region to extract its information. diff --git a/src/ipyaladin/widget.py b/src/ipyaladin/widget.py index c9ac59e4..f68749a7 100644 --- a/src/ipyaladin/widget.py +++ b/src/ipyaladin/widget.py @@ -20,6 +20,7 @@ import warnings import anywidget +import ipywidgets as widgets from astropy.coordinates import SkyCoord, Angle, Longitude, Latitude from astropy.coordinates.name_resolve import NameResolveError from astropy.table.table import QTable, Table @@ -40,6 +41,7 @@ _error_radius_conversion_factor, ) from .elements.marker import Marker +from .utils._region_converter import RegionInfos try: from regions import ( @@ -180,6 +182,9 @@ class Aladin(anywidget.AnyWidget): "SIN", help="The projection for the view. The keywords follow the FITS standard.", ).tag(sync=True, init_option=True) + colormap = Unicode( + help="The colormap of the main survey", + ).tag(sync=True) # Values _ready = Bool( False, @@ -210,6 +215,74 @@ class Aladin(anywidget.AnyWidget): "to convert the view to an astropy.HDUList", ).tag(sync=True) + overlays = traitlets.List( + traitlets.Dict( + per_key_traits={ + "action": traitlets.Enum(["add", "remove"]).tag(sync=True), + "options": traitlets.Dict().tag(sync=True), + "data": traitlets.Union( + [ + # fits-image, astropy/numpy tables + traitlets.Bytes(), + # URLs of catalogs, MOCs + traitlets.Unicode(), + # JSON MOC + traitlets.Dict(), + # Sky regions (from stcs or astropy.regions objects) + traitlets.List(trait=RegionInfos("")), + ] + ).tag(sync=True, **widgets.widget_serialization), + "type": traitlets.Enum( + [ + "table", + "catalog_from_url", + "MOC_from_url", + "MOC_from_dict", + "regions_stcs", + "regions", + "fits-image", + ] + ).tag(sync=True), + }, + help="A trait that keeps the history of the overlays added/removed " + "by the user. This is used to generate a synced view of ipyaladin " + "when exporting the notebook to static HTML files (e.g. when " + "generating the docs).", + ).tag(sync=True) + ).tag(sync=True) + + _overlay_patch = traitlets.Dict( + per_key_traits={ + "action": traitlets.Enum(["add", "remove"]).tag(sync=True), + "options": traitlets.Dict().tag(sync=True), + "data": traitlets.Union( + [ + # fits-image, astropy/numpy tables + traitlets.Bytes(), + # URLs of catalogs, MOCs + traitlets.Unicode(), + # JSON MOC + traitlets.Dict(), + # Sky regions (from stcs or astropy.regions objects) + traitlets.List(trait=RegionInfos("")), + ] + ).tag(sync=True, **widgets.widget_serialization), + "type": traitlets.Enum( + [ + "table", + "catalog_from_url", + "MOC_from_url", + "MOC_from_dict", + "regions_stcs", + "regions", + "fits-image", + ] + ).tag(sync=True), + }, + help="A private trait that registers the action made by the user " + "concerning the add/removing of overlays (MOC, table, fits image, regions).", + ).tag(sync=True) + _is_loaded = Bool( False, help="A private trait that stores whether the widget is loaded.", @@ -679,6 +752,12 @@ def get_JPEG_thumbnail(self) -> None: """ self.send({"event_name": "get_JPG_thumbnail"}) + def _update_overlays(self, overlay_patch: dict) -> None: + self._overlay_patch = overlay_patch + # Update the canonical state as well. + # This is listened only when ipyaladin is run from a static HTML page + self.overlays = [*self.overlays, overlay_patch] + @widget_should_be_loaded def add_catalog_from_URL( self, votable_URL: str, votable_options: Optional[dict] = None @@ -693,10 +772,11 @@ def add_catalog_from_URL( """ if votable_options is None: votable_options = {} - self.send( + self._update_overlays( { - "event_name": "add_catalog_from_URL", - "votable_URL": votable_URL, + "action": "add", + "type": "catalog_from_url", + "data": votable_URL, "options": votable_options, } ) @@ -724,10 +804,13 @@ def add_fits(self, fits: Union[str, Path, HDUList], **image_options: any) -> Non fits_bytes = io.BytesIO() fits.writeto(fits_bytes) - self._wcs = {} - self.send( - {"event_name": "add_fits", "options": image_options}, - buffers=[fits_bytes.getvalue()], + self._update_overlays( + { + "action": "add", + "type": "fits-image", + "data": fits_bytes.getvalue(), + "options": image_options, + } ) # MOCs @@ -749,18 +832,20 @@ def add_moc(self, moc: any, **moc_options: any) -> None: """ if isinstance(moc, dict): - self.send( + self._update_overlays( { - "event_name": "add_MOC_from_dict", - "moc_dict": moc, + "action": "add", + "type": "MOC_from_dict", + "data": moc, "options": moc_options, } ) elif isinstance(moc, str) and "://" in moc: - self.send( + self._update_overlays( { - "event_name": "add_MOC_from_URL", - "moc_URL": moc, + "action": "add", + "type": "MOC_from_url", + "data": moc, "options": moc_options, } ) @@ -769,10 +854,11 @@ def add_moc(self, moc: any, **moc_options: any) -> None: from mocpy import MOC # noqa: PLC0415 if isinstance(moc, MOC): - self.send( + self._update_overlays( { - "event_name": "add_MOC_from_dict", - "moc_dict": moc.serialize("json"), + "action": "add", + "type": "MOC_from_dict", + "data": moc.serialize("json"), "options": moc_options, } ) @@ -901,9 +987,13 @@ def add_table( table_options["shape"] = shape table_bytes = io.BytesIO() table.write(table_bytes, format="votable") - self.send( - {"event_name": "add_table", "options": table_options}, - buffers=[table_bytes.getvalue()], + self._update_overlays( + { + "action": "add", + "type": "table", + "options": table_options, + "data": table_bytes.getvalue(), + } ) @widget_should_be_loaded @@ -978,11 +1068,12 @@ def add_graphic_overlay_from_region( # Define behavior for each region type regions_infos.append(RegionInfos(region_element).to_clean_dict()) - self.send( + self._update_overlays( { - "event_name": "add_overlay", - "regions_infos": regions_infos, - "graphic_options": graphic_options, + "action": "add", + "type": "regions", + "options": graphic_options, + "data": regions_infos, } ) @@ -1042,12 +1133,12 @@ def add_graphic_overlay_from_stcs( } for region_element in region_list ] - - self.send( + self._update_overlays( { - "event_name": "add_overlay", - "regions_infos": regions_infos, - "graphic_options": overlay_options, + "action": "add", + "type": "regions_stcs", + "options": overlay_options, + "data": regions_infos, } ) @@ -1061,7 +1152,7 @@ def set_color_map(self, color_map_name: str) -> None: The name of the color map to use. """ - self.send({"event_name": "change_colormap", "colormap": color_map_name}) + self.colormap = color_map_name def selection(self, selection_type: str = "rectangle") -> None: """Trigger the selection tool. diff --git a/src/tests/test_aladin.py b/src/tests/test_aladin.py index 3c922270..314a3514 100644 --- a/src/tests/test_aladin.py +++ b/src/tests/test_aladin.py @@ -5,7 +5,6 @@ from astropy.table import Column, Table import numpy as np import pytest -from unittest.mock import Mock from ipyaladin import Aladin from ipyaladin.elements.error_shape import EllipseError, CircleError @@ -28,6 +27,23 @@ def mock_sesame(monkeypatch: Callable) -> None: monkeypatch.setattr(SkyCoord, "from_name", lambda _: SkyCoord(0, 0, unit="deg")) +# Fixture to record trailet change on aladin +@pytest.fixture +def trait_recorder() -> Callable[[str], list]: + """Fixture to record traitlet changes on a widget instance.""" + + def _attach(trait_name: str) -> list: + events = [] + + def _recorder(change: any) -> None: + events.append(change) + + aladin.observe(_recorder, trait_name) + return events + + return _attach + + class MockResponse: """Mock response object for requests.get.""" @@ -232,7 +248,7 @@ def test_aladin_init_rotation(angle: Union[float, u.Quantity]) -> None: @pytest.mark.parametrize("stcs_strings", test_stcs_iterables) def test_add_graphic_overlay_from_stcs_iterables( - monkeypatch: Callable, + trait_recorder: Callable[[str], list], stcs_strings: Union[Iterable[str], str], ) -> None: """Test generating region overlay info from iterable STC-S string(s). @@ -243,10 +259,11 @@ def test_add_graphic_overlay_from_stcs_iterables( The stcs strings to create region overlay info from. """ - mock_send = Mock() - monkeypatch.setattr(Aladin, "send", mock_send) + events = trait_recorder("_overlay_patch") + aladin.add_graphic_overlay_from_stcs(stcs_strings) - regions_info = mock_send.call_args[0][0]["regions_infos"] + + regions_info = events[0].new["data"] assert isinstance(regions_info, list) assert regions_info[0]["infos"]["stcs"] in stcs_strings @@ -263,7 +280,7 @@ def test_add_graphic_overlay_from_stcs_iterables( def test_add_graphic_overlay_from_stcs_noniterables( stcs_strings: Union[Iterable[str], str], ) -> None: - """Test generating region overlay info from iterable STC-S string(s). + """Error check when a non iterable input of STC-S string(s) is given. Parameters ---------- @@ -276,39 +293,35 @@ def test_add_graphic_overlay_from_stcs_noniterables( assert info.type is TypeError -def test_add_table(monkeypatch: Callable) -> None: - """Test generating region overlay info from iterable STC-S string(s). - - Parameters - ---------- - stcs_strings : Union[Iterable[str], str] - The stcs strings to create region overlay info from. +def test_add_table(trait_recorder: Callable[[str], list]) -> None: + """Test sending an astropy table to ipyaladin. + This test checks that the overlay traitlet is correctly synced """ + events = trait_recorder("_overlay_patch") + table = Table({"a": [1, 2, 3]}) table["a"].unit = "deg" - mock_send = Mock() - monkeypatch.setattr(Aladin, "send", mock_send) # normal table call aladin.add_table(table) - table_sent_message = mock_send.call_args[0][0] - assert table_sent_message["event_name"] == "add_table" + table_sent = events[0].new + assert table_sent["type"] == "table" and table_sent["action"] == "add" # circle error aladin.add_table( table, shape=CircleError(radius="a", default_shape="cross"), color="pink" ) - table_sent_message = mock_send.call_args[0][0] - assert table_sent_message["options"]["circle_error"] == { + table_sent = events[1].new + assert table_sent["options"]["circle_error"] == { "radius": "a", "conversion_radius": 1, } - assert table_sent_message["options"]["shape"] == "cross" + assert table_sent["options"]["shape"] == "cross" # ellipse error aladin.add_table(table, shape=EllipseError(maj_axis="a", min_axis="a", angle="a")) - table_sent_message = mock_send.call_args[0][0] + table_sent = events[2].new ellipse_options = { "maj_axis": "a", "min_axis": "a", @@ -317,4 +330,4 @@ def test_add_table(monkeypatch: Callable) -> None: "conversion_min_axis": 1, "conversion_maj_axis": 1, } - assert table_sent_message["options"]["ellipse_error"] == ellipse_options + assert table_sent["options"]["ellipse_error"] == ellipse_options