Two patterns that work with no kernel on a static page:
a search-result table linked to a footprint map — click a row or a polygon and the other side follows (
jslinkonselected);a quantized grid raster the reader can re-stretch, re-colour and switch between bands / an RGB composite, entirely in the browser.
The data below is synthetic so this page builds anywhere.
import numpy as np
from ipywidgets import jslink
from manywidgets import Column, GeoMap, GridLayer, Table
rng = np.random.default_rng(7)
# Eight fake "granules": tilted rectangles along a track near Death Valley.
features, rows = [], []
for i in range(8):
lon0, lat0 = -117.4 + 0.18 * i, 35.6 + 0.12 * i
ring = [[lon0, lat0], [lon0 + 0.5, lat0 + 0.08], [lon0 + 0.42, lat0 + 0.46], [lon0 - 0.08, lat0 + 0.38], [lon0, lat0]]
gid = f"G{i+1:03d}"
features.append({"type": "Feature", "properties": {"granule": gid, "track": 4, "frame": 4000 + i}, "geometry": {"type": "Polygon", "coordinates": [ring]}})
rows.append({"granule": gid, "start": f"2025-10-{10+i:02d}T11:1{i}:30Z", "frame": 4000 + i, "polarization": "DHDH", "size": int(rng.integers(4_500_000_000, 6_000_000_000))})
table = Table(
rows,
columns=[
{"key": "granule", "label": "Granule"},
{"key": "start", "label": "Start", "format": "datetime"},
{"key": "frame", "label": "Frame"},
{"key": "polarization", "label": "Pol"},
{"key": "size", "label": "Size", "format": "bytes"},
],
id_key="granule",
selected="G003",
max_height="220px",
)
fmap = GeoMap(basemap="positron", height="380px", controls=False)
fmap.add_geojson({"type": "FeatureCollection", "features": features}, id="footprints", id_property="granule", tooltip=["granule", "frame"])
fmap.selected = "G003"
jslink((table, "selected"), (fmap, "selected"))
jslink((table, "hovered"), (fmap, "hovered"))
Column(table, fmap)A grid raster with client-side styling¶
GridLayer.from_arrays quantizes each band to one byte per pixel (0 = nodata)
and places the image by its four outer corners. The panel’s band / stretch /
colormap / opacity changes are applied in the browser.
y, x = np.mgrid[0:240, 0:320]
hh = -18 + 6 * np.sin(x / 25) * np.cos(y / 20) + rng.normal(0, 1.5, x.shape)
hv = hh - 7 + rng.normal(0, 1, x.shape)
hh[20:40, 20:60] = np.nan # a nodata hole
corners = [[-116.78, 36.06], [-116.52, 36.09], [-116.49, 35.88], [-116.75, 35.85]] # TL, TR, BR, BL (slightly rotated)
grid = GridLayer.from_arrays(
{"HH": hh, "HV": hv, "HH/HV": hh - hv},
corners,
id="window",
label="Backscatter window",
quantize={"HH": (-30, 5), "HV": (-35, 0), "HH/HV": (0, 15)},
units="dB",
default_ranges={"HH": (-25, -10), "HV": (-32, -17), "HH/HV": (3, 11)},
composites={"Dual-pol RGB": ["HH", "HV", "HH/HV"]},
)
print(f"grid payload: {grid.nbytes / 1e6:.2f} MB")
gmap = GeoMap(basemap="satellite", height="420px")
gmap.add_grid(grid)
gmap.add_geojson(
{"type": "Feature", "properties": {"name": "scene"}, "geometry": {"type": "Polygon", "coordinates": [[[-116.9, 36.2], [-116.4, 36.25], [-116.35, 35.75], [-116.85, 35.7], [-116.9, 36.2]]]}},
id="scene", id_property="name", fill_opacity=0.0, line="#ffd166", line_width=2,
)
gmapgrid payload: 0.31 MB