diff --git a/src/entropice/dashboard/plots/grids.py b/src/entropice/dashboard/plots/grids.py new file mode 100644 index 0000000..82d5e8d --- /dev/null +++ b/src/entropice/dashboard/plots/grids.py @@ -0,0 +1,123 @@ +"""Plots for visualizing grid statistics.""" + +import geopandas as gpd +import matplotlib.colors as mcolors +import numpy as np +import pydeck as pdk + +from entropice.dashboard.utils.colors import get_cmap, hex_to_rgb +from entropice.dashboard.utils.geometry import fix_hex_geometry + + +def create_grid_areas_map( + grid_gdf: gpd.GeoDataFrame, + metric: str, + make_3d_map: bool, +) -> pdk.Deck: + """Create a spatial distribution map for grid areas. + + Args: + grid_gdf (gpd.GeoDataFrame): GeoDataFrame containing grid cell geometries and statistics. + metric (str): The metric to visualize (e.g., "cell_area", "land_area", "water_area", "land_ratio"). + make_3d_map (bool): Whether to render the map in 3D (extruded) or 2D. + + Returns: + pdk.Deck: A PyDeck map visualization of the specified grid statistic. + + """ + # Create a copy to avoid modifying the original + gdf = grid_gdf.copy().to_crs("EPSG:4326") + + # Fix antimeridian issues for hex cells + gdf["geometry"] = gdf["geometry"].apply(fix_hex_geometry) + + # Convert to WGS84 for pydeck + gdf_wgs84 = gdf.to_crs("EPSG:4326") + + # Get colormap for the metric + cmap = get_cmap(metric) + + # Normalize the metric values to [0, 1] for color mapping + values = gdf_wgs84[metric].values + vmin, vmax = values.min(), values.max() + + if vmax > vmin: + normalized_values = (values - vmin) / (vmax - vmin) + else: + normalized_values = np.zeros_like(values) + + # Map normalized values to colors + colors = [cmap(val) for val in normalized_values] + rgb_colors = [hex_to_rgb(mcolors.to_hex(color)) for color in colors] + gdf_wgs84["fill_color"] = rgb_colors + + # Store metric value for tooltip + gdf_wgs84["metric_value"] = values + + # Store normalized values for elevation (if 3D) + gdf_wgs84["elevation"] = normalized_values + + # Convert to GeoJSON format + geojson_data = [] + for _, row in gdf_wgs84.iterrows(): + feature = { + "type": "Feature", + "geometry": row["geometry"].__geo_interface__, + "properties": { + "fill_color": row["fill_color"], + "metric_value": float(row["metric_value"]), + "elevation": float(row["elevation"]) if make_3d_map else 0, + "cell_area": float(row["cell_area"]), + "land_area": float(row["land_area"]), + "water_area": float(row["water_area"]), + "land_ratio": float(row["land_ratio"]), + }, + } + geojson_data.append(feature) + + # Create pydeck layer + layer = pdk.Layer( + "GeoJsonLayer", + geojson_data, + opacity=0.7, + stroked=True, + filled=True, + extruded=make_3d_map, + wireframe=False, + get_fill_color="properties.fill_color", + get_line_color=[80, 80, 80], + line_width_min_pixels=0.5, + get_elevation="properties.elevation" if make_3d_map else 0, + elevation_scale=500000, # Scale normalized values (0-1) to 500km height + pickable=True, + ) + + # Set initial view state (centered on the Arctic) + # Adjust pitch and zoom based on whether we're using 3D + view_state = pdk.ViewState( + latitude=70, + longitude=0, + zoom=2 if not make_3d_map else 1.5, + pitch=0 if not make_3d_map else 45, + ) + + # Build tooltip HTML + tooltip_html = ( + "Cell Area: {cell_area} km²
" + "Land Area: {land_area} km²
" + "Water Area: {water_area} km²
" + "Land Ratio: {land_ratio}" + ) + + # Create deck + deck = pdk.Deck( + layers=[layer], + initial_view_state=view_state, + tooltip={ + "html": tooltip_html, + "style": {"backgroundColor": "steelblue", "color": "white"}, + }, + map_style="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + ) + + return deck diff --git a/src/entropice/dashboard/sections/areas.py b/src/entropice/dashboard/sections/areas.py new file mode 100644 index 0000000..dd4c732 --- /dev/null +++ b/src/entropice/dashboard/sections/areas.py @@ -0,0 +1,104 @@ +"""Area of grid cells dashboard section.""" + +from typing import cast + +import geopandas as gpd +import matplotlib.colors as mcolors +import streamlit as st + +from entropice.dashboard.plots.grids import create_grid_areas_map +from entropice.dashboard.utils.colors import get_cmap + + +@st.fragment +def _render_area_map(grid_gdf: gpd.GeoDataFrame): + st.subheader("Spatial Distribution of Grid Cell Areas") + + cols = st.columns([4, 1]) + with cols[0]: + metric = st.selectbox( + "Metric", + options=["cell_area", "land_area", "water_area", "land_ratio"], + format_func=lambda x: x.replace("_", " ").title(), + key="metric", + ) + with cols[1]: + make_3d_map = cast(bool, st.checkbox("3D Map", value=True, key="area_3d_map")) + + map_deck = create_grid_areas_map(grid_gdf, metric, make_3d_map) + st.pydeck_chart(map_deck) + + # Add legend + with st.expander("Legend", expanded=True): + st.markdown(f"**{metric.replace('_', ' ').title()}**") + + values = grid_gdf[metric] + vmin, vmax = values.min(), values.max() + + # Format values based on metric type + if metric == "land_ratio": + vmin_str = f"{vmin:.1%}" + vmax_str = f"{vmax:.1%}" + else: + vmin_str = f"{vmin:.2f} km²" + vmax_str = f"{vmax:.2f} km²" + + # Get the same colormap used in the map + cmap = get_cmap(metric) + # Sample 4 colors from the colormap to create the gradient + gradient_colors = [mcolors.to_hex(cmap(i)) for i in [0.0, 0.33, 0.67, 1.0]] + gradient_css = ", ".join(gradient_colors) + + # Create a simple gradient legend + st.markdown( + f'
' + f'{vmin_str}' + f'
' + f'{vmax_str}' + f"
", + unsafe_allow_html=True, + ) + + st.caption("Color intensity represents the metric value from low (purple) to high (yellow).") + + if make_3d_map: + st.markdown("---") + st.markdown("**3D Elevation:**") + st.caption( + f"Height represents normalized {metric.replace('_', ' ')} values. " + "Rotate the map by holding Ctrl/Cmd and dragging." + ) + + +def render_area_information_tab(grid_gdf: gpd.GeoDataFrame): + """Render grid cell areas and land/water distribution. + + Args: + grid_gdf: Pre-loaded grid GeoDataFrame. + + """ + st.markdown("### Grid Cell Areas and Land/Water Distribution") + + st.markdown( + "This visualization shows the spatial distribution of cell areas, land areas, " + "water areas, and land ratio across the grid. The grid has been filtered to " + "include only cells in the permafrost region (>50° latitude, <85° latitude) " + "with >10% land coverage." + ) + + # Show summary statistics + col1, col2, col3, col4 = st.columns(4) + with col1: + st.metric("Total Cells", f"{len(grid_gdf):,}") + with col2: + st.metric("Avg Cell Area", f"{grid_gdf['cell_area'].mean():.2f} km²") + with col3: + st.metric("Avg Land Ratio", f"{grid_gdf['land_ratio'].mean():.1%}") + with col4: + total_land = grid_gdf["land_area"].sum() + st.metric("Total Land Area", f"{total_land:,.0f} km²") + + st.divider() + + _render_area_map(grid_gdf) diff --git a/src/entropice/dashboard/views/dataset_page.py b/src/entropice/dashboard/views/dataset_page.py index 1fab911..e2b0a19 100644 --- a/src/entropice/dashboard/views/dataset_page.py +++ b/src/entropice/dashboard/views/dataset_page.py @@ -5,6 +5,7 @@ from typing import cast import streamlit as st from stopuhr import stopwatch +from entropice.dashboard.sections.areas import render_area_information_tab from entropice.dashboard.sections.dataset_statistics import render_ensemble_details from entropice.dashboard.sections.targets import render_target_information_tab from entropice.dashboard.utils.stats import DatasetStatistics @@ -120,6 +121,8 @@ def render_dataset_page(): train_data_dict[target] = {} for task in all_tasks: train_data_dict[target][task] = ensemble.create_training_set(target=target, task=task) + # Preload the grid GeoDataFrame + grid_gdf = ensemble.read_grid() era5_members = [m for m in ensemble.members if m.startswith("ERA5")] # Create tabs for different data views @@ -135,9 +138,11 @@ def render_dataset_page(): with tabs[0]: st.header("🎯 Target Labels Visualization") - render_target_information_tab(train_data_dict) + if False: #! debug + render_target_information_tab(train_data_dict) with tabs[1]: st.header("📐 Areas Visualization") + render_area_information_tab(grid_gdf) tab_index = 2 if "AlphaEarth" in ensemble.members: with tabs[tab_index]: