Source code for raddb.viz.interactive

"""
raddb/viz/interactive.py
------------------------
Interactive Area-of-Interest selection on an ipyleaflet map (Jupyter).

Draw a shape on the map and it is dispatched to the matching RadDB method:

    rectangle  ->  crop_by_bbox
    polygon    ->  crop_by_polygone
    marker     ->  crop_around_point       (distance from the widget)
    polyline   ->  extract_cross_section   (first & last vertex)

The map is WGS-84 (lon/lat), so the drawn coordinates are passed with ``crs=4326``
(reprojected to LV95 internally).  Each call returns a new data-carrying
``RadDB``.  The drawn AOI can be saved as GeoJSON and reloaded later via
``crop_by_polygone("aoi.geojson")``.

This is an **optional** UI layer — it needs ``ipyleaflet`` + ``ipywidgets`` and a
Jupyter frontend.  The hand-typed / shapefile crop methods are unaffected.
"""
from __future__ import annotations

import json
from pathlib import Path


# ============================================================================
# GeoJSON feature -> crop dispatch  (pure, unit-testable — no widgets)
# ============================================================================

def _is_axis_aligned_box(ring: list) -> bool:
    """True if a polygon ring is an axis-aligned rectangle (a drawn 'rectangle')."""
    pts = ring[:-1] if ring and ring[0] == ring[-1] else ring
    if len(pts) != 4:
        return False
    lons = {round(p[0], 9) for p in pts}
    lats = {round(p[1], 9) for p in pts}
    return len(lons) == 2 and len(lats) == 2


def _crop_from_feature(db, feature, distance_m: float = 15_000.0):
    """Dispatch a drawn GeoJSON feature (lon/lat) to the matching ``db`` method.

    Parameters
    ----------
    db : RadDB
        A data-carrying RadDB (from :meth:`RadDB.open`).
    feature : dict
        A GeoJSON Feature or bare geometry (from ipyleaflet's draw control).
    distance_m : float
        Distance (metres) used when the drawn shape is a marker (Point).

    Returns
    -------
    (kind, rdf) : (str, RadDB)
        ``kind`` is one of ``"bbox" | "polygon" | "point" | "cross_section"``.
    """
    import shapely.geometry

    geom = feature.get("geometry", feature)
    gtype = geom["type"]

    if gtype == "Point":
        lon, lat = geom["coordinates"][:2]
        return "point", db.crop_around_point((lon, lat), distance_m, crs=4326)

    if gtype == "LineString":
        coords = geom["coordinates"]
        p1, p2 = tuple(coords[0][:2]), tuple(coords[-1][:2])
        return "cross_section", db.extract_cross_section(p1, p2, crs=4326)

    if gtype == "Polygon":
        ring = geom["coordinates"][0]
        if _is_axis_aligned_box(ring):
            lons = [p[0] for p in ring]
            lats = [p[1] for p in ring]
            bounds = (min(lons), min(lats), max(lons), max(lats))
            return "bbox", db.crop_by_bbox(bounds=bounds, crs=4326)
        return "polygon", db.crop_by_polygone(shapely.geometry.shape(geom), crs=4326)

    raise ValueError(f"unsupported drawn geometry type: {gtype!r}")


def _feature_collection(feature) -> dict:
    """Wrap a drawn feature as a GeoJSON FeatureCollection (WGS-84) for saving."""
    if feature.get("type") == "Feature":
        feat = feature
    else:  # bare geometry
        feat = {"type": "Feature", "properties": {}, "geometry": feature}
    return {"type": "FeatureCollection", "features": [feat]}


# ============================================================================
# The ipyleaflet widget
# ============================================================================

[docs] class AOISelector: """Interactive map to draw an AOI and crop a DataFrame with it. Displayed automatically in Jupyter (returns from :meth:`RadDB.interactive_crop`). After drawing a shape and clicking **Apply crop**, the result is available on: - :attr:`result` — the cropped ``RadDB``, - :attr:`kind` — which crop ran (``bbox``/``polygon``/``point``/``cross_section``), - :attr:`feature` — the drawn GeoJSON feature (lon/lat). Parameters ---------- db : RadDB A data-carrying RadDB (from :meth:`RadDB.open`). radars : list of str, optional Radars to mark on the map. Default: the radars in ``db``. center : (lat, lon), optional Initial map centre. Default: mean of the radar sites, else Switzerland. zoom : int point_radius_m : float Default distance (metres) for the marker (point) crop. """ def __init__(self, db, radars=None, center=None, zoom=8, point_radius_m=15_000.0): try: import ipywidgets as W from ipyleaflet import Map, DrawControl, Marker, AwesomeIcon except ImportError as exc: # pragma: no cover - optional dependency raise ImportError( "interactive_crop needs ipyleaflet + ipywidgets. " "Install with: pip install ipyleaflet ipywidgets" ) from exc self.db = db self.feature = None self.result = None self.kind = None radars = radars if radars is not None else db.radars() sites = self._radar_sites(radars) if center is None: center = ( (sum(s[0] for s in sites.values()) / len(sites), sum(s[1] for s in sites.values()) / len(sites)) if sites else (46.82, 8.23) ) self.map = Map(center=center, zoom=zoom, scroll_wheel_zoom=True) for r, (lat, lon) in sites.items(): self.map.add(Marker(location=(lat, lon), title=f"radar {r}", draggable=False, icon=AwesomeIcon(name="broadcast-tower", marker_color="red"))) self.draw = DrawControl( rectangle={"shapeOptions": {"color": "#e31a1c", "weight": 2, "fillOpacity": 0.05}}, polygon={"shapeOptions": {"color": "#e31a1c", "weight": 2, "fillOpacity": 0.05}}, polyline={"shapeOptions": {"color": "#e31a1c", "weight": 3}}, marker={"shapeOptions": {}}, circle={}, circlemarker={}, ) self.draw.on_draw(self._on_draw) self.map.add(self.draw) # --- controls --- self.radius = W.FloatText(value=float(point_radius_m), description="point r [m]", style={"description_width": "initial"}, layout=W.Layout(width="180px")) self.apply_btn = W.Button(description="Apply crop", button_style="primary", icon="scissors") self.save_path = W.Text(value="aoi.geojson", description="save as", layout=W.Layout(width="240px")) self.save_btn = W.Button(description="Save GeoJSON", icon="save") self.out = W.Output() self.apply_btn.on_click(self._apply) self.save_btn.on_click(self._save) instructions = W.HTML( "<b>Draw an AOI</b> with the toolbar (top-left): " "▭ rectangle → <code>crop_by_bbox</code>, ⬠ polygon → <code>crop_by_polygone</code>, " "📍 marker → <code>crop_around_point</code> (uses <i>point r</i>), " "/ line → <code>extract_cross_section</code>. Then <b>Apply crop</b>." ) self._widget = W.VBox([ instructions, self.map, W.HBox([self.radius, self.apply_btn, self.save_path, self.save_btn]), self.out, ]) # -- radar site coords (lat, lon) -- def _radar_sites(self, radars): sites = {} for r in radars: try: info = self.db.get_radar_info(r) sites[r] = (float(info["latitude"]), float(info["longitude"])) except Exception: # noqa: BLE001 - a missing radar shouldn't break the map continue return sites # -- draw callback: keep the last drawn feature -- def _on_draw(self, target, action, geo_json): if action in ("created", "edited"): self.feature = geo_json def _apply(self, _btn=None): with self.out: self.out.clear_output() if self.feature is None: print("Draw a shape on the map first (rectangle / polygon / marker / line).") return try: self.kind, self.result = _crop_from_feature( self.db, self.feature, distance_m=self.radius.value, ) except Exception as exc: # noqa: BLE001 - surface errors in the widget print(f"crop failed: {exc}") return r = self.result try: radars = r.radars() if len(r) else [] except Exception: # noqa: BLE001 radars = [] sweeps = r.data["sweep"].n_unique() if "sweep" in r.columns() else "?" print(f"{self.kind} -> {len(r):,} gates | radars {radars} | {sweeps} sweeps") print("result is available as <selector>.result (a cropped RadDB).") def _save(self, _btn=None): with self.out: if self.feature is None: print("Nothing to save — draw a shape first.") return path = Path(self.save_path.value) path.write_text(json.dumps(_feature_collection(self.feature), indent=1)) print(f"saved AOI -> {path} (reload with db.crop_by_polygone('{path}') for polygons)")
[docs] def display(self): from IPython.display import display display(self._widget) return self
def _ipython_display_(self): from IPython.display import display display(self._widget)