Source code for satorbis_kit.visualization.utils
"""Utility functions for STAC visualization."""
import json
from datetime import datetime
from typing import Dict, List, Optional, Tuple
[docs]
def get_item_bounds(item: Dict) -> List[float]:
"""Extract bounding box from a STAC item.
Args:
item: STAC item dictionary
Returns:
Bounding box as [west, south, east, north]
Example:
>>> item = {"bbox": [-180, -90, 180, 90]}
>>> bounds = get_item_bounds(item)
>>> bounds
[-180, -90, 180, 90]
"""
if "bbox" in item:
return item["bbox"]
# Try to extract from geometry
if "geometry" in item and item["geometry"]:
coords = item["geometry"]["coordinates"]
# Handle different geometry types
if item["geometry"]["type"] == "Polygon":
lons = [coord[0] for coord in coords[0]]
lats = [coord[1] for coord in coords[0]]
elif item["geometry"]["type"] == "MultiPolygon":
lons = [coord[0] for polygon in coords for coord in polygon[0]]
lats = [coord[1] for polygon in coords for coord in polygon[0]]
else:
# Fallback to properties if available
return item.get("properties", {}).get("bbox", [-180, -90, 180, 90])
return [min(lons), min(lats), max(lons), max(lats)]
# Default fallback
return [-180, -90, 180, 90]
[docs]
def format_metadata(item: Dict, max_properties: int = 10) -> str:
"""Format STAC item metadata as HTML for popup display.
Args:
item: STAC item dictionary
max_properties: Maximum number of properties to display
Returns:
HTML string for popup
"""
html_parts = [
f"<div style='max-width: 400px; font-family: Arial, sans-serif;'>",
f"<h3 style='margin: 0 0 10px 0; color: #333;'>{item.get('id', 'STAC Item')}</h3>",
]
# Collection
if "collection" in item:
html_parts.append(
f"<p style='margin: 5px 0;'><strong>Collection:</strong> {item['collection']}</p>"
)
# Datetime
if "properties" in item and "datetime" in item["properties"]:
dt_str = item["properties"]["datetime"]
try:
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
formatted_dt = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
html_parts.append(
f"<p style='margin: 5px 0;'><strong>Date:</strong> {formatted_dt}</p>"
)
except:
html_parts.append(f"<p style='margin: 5px 0;'><strong>Date:</strong> {dt_str}</p>")
# Assets
if "assets" in item:
asset_count = len(item["assets"])
asset_names = list(item["assets"].keys())[:5]
html_parts.append(
f"<p style='margin: 5px 0;'><strong>Assets:</strong> {asset_count} "
f"({', '.join(asset_names)}{'...' if asset_count > 5 else ''})</p>"
)
# Selected properties
if "properties" in item:
props = item["properties"]
displayed = 0
html_parts.append(
"<div style='margin-top: 10px;'><strong>Properties:</strong><ul style='margin: 5px 0; padding-left: 20px;'>"
)
# Prioritize certain properties
priority_props = ["eo:cloud_cover", "gsd", "platform", "instruments", "proj:epsg"]
for key in priority_props:
if key in props and displayed < max_properties:
value = props[key]
display_key = key.replace(":", " ").title()
html_parts.append(f"<li><em>{display_key}:</em> {value}</li>")
displayed += 1
# Add other properties
for key, value in props.items():
if displayed >= max_properties:
break
if key not in priority_props and key != "datetime":
display_key = key.replace(":", " ").replace("_", " ").title()
# Truncate long values
str_value = str(value)
if len(str_value) > 50:
str_value = str_value[:47] + "..."
html_parts.append(f"<li><em>{display_key}:</em> {str_value}</li>")
displayed += 1
html_parts.append("</ul></div>")
# Links
if "links" in item:
self_link = next(
(link["href"] for link in item["links"] if link.get("rel") == "self"), None
)
if self_link:
html_parts.append(
f"<p style='margin: 10px 0 0 0;'><a href='{self_link}' target='_blank' "
f"style='color: #0066cc;'>View Item JSON</a></p>"
)
html_parts.append("</div>")
return "".join(html_parts)
[docs]
def create_color_palette(
name: str = "viridis",
n_colors: int = 256,
) -> List[Tuple[int, int, int]]:
"""Create a color palette for visualization.
Args:
name: Palette name ('viridis', 'plasma', 'inferno', 'magma', 'cividis')
n_colors: Number of colors in the palette
Returns:
List of RGB tuples
"""
try:
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
cmap = plt.get_cmap(name)
colors = [cmap(i / n_colors) for i in range(n_colors)]
# Convert to RGB integers
return [(int(r * 255), int(g * 255), int(b * 255)) for r, g, b, _ in colors]
except ImportError:
# Fallback to simple grayscale
return [(i, i, i) for i in range(0, 256, 256 // n_colors)]
[docs]
def calculate_center(bounds: List[float]) -> List[float]:
"""Calculate center point from bounding box.
Args:
bounds: Bounding box [west, south, east, north]
Returns:
Center point [lat, lon]
"""
return [(bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2]
[docs]
def merge_bounds(bounds_list: List[List[float]]) -> List[float]:
"""Merge multiple bounding boxes into one.
Args:
bounds_list: List of bounding boxes
Returns:
Merged bounding box [west, south, east, north]
"""
if not bounds_list:
return [-180, -90, 180, 90]
wests = [b[0] for b in bounds_list]
souths = [b[1] for b in bounds_list]
easts = [b[2] for b in bounds_list]
norths = [b[3] for b in bounds_list]
return [min(wests), min(souths), max(easts), max(norths)]
[docs]
def get_optimal_zoom(bounds: List[float], map_width: int = 800, map_height: int = 600) -> int:
"""Calculate optimal zoom level for bounding box.
Args:
bounds: Bounding box [west, south, east, north]
map_width: Map width in pixels
map_height: Map height in pixels
Returns:
Zoom level (1-22)
"""
import math
# Calculate degrees per pixel
lon_range = abs(bounds[2] - bounds[0])
lat_range = abs(bounds[3] - bounds[1])
# Mercator projection considerations
lat_center = (bounds[1] + bounds[3]) / 2
# Calculate zoom levels for width and height
zoom_lon = math.log2(map_width * 360 / (lon_range * 256)) if lon_range > 0 else 22
zoom_lat = (
math.log2(map_height * 360 / (lat_range * 256 * math.cos(math.radians(lat_center))))
if lat_range > 0
else 22
)
# Use the minimum zoom to fit both dimensions
zoom = min(zoom_lon, zoom_lat, 22)
return max(1, int(zoom))