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))