hytch / components.py
LeonceNsh's picture
Update components.py
ebf7153 verified
"""
Reusable UI components for Gradio dashboard.
Filters, KPI tiles, tables, and chart templates.
"""
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, timedelta
import logging
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from config import COLOR_PALETTE, KPI_FORMATS, MAP_STYLE, MAPBOX_TOKEN
logger = logging.getLogger(__name__)
# =============================================================================
# FILTER COMPONENTS
# =============================================================================
def create_date_range_inputs() -> Tuple[datetime, datetime]:
"""Create default date range (last 90 days)."""
end_date = datetime.now()
start_date = end_date - timedelta(days=90)
return start_date, end_date
def create_filter_options() -> Dict[str, List]:
"""Create filter options for dropdowns."""
return {
"granularity": ["day", "week", "month"],
"driver_types": ["All", "Owner", "Participant", "External"],
"trip_types": ["All", "Solo", "Shared"],
"geo_levels": ["state", "city", "zip"],
"impact_grades": ["All", "A+", "A", "B", "C", "D", "F"]
}
# =============================================================================
# KPI TILE COMPONENTS
# =============================================================================
def create_kpi_tile(
title: str,
value: Any,
format_type: str = "users",
delta: Optional[float] = None,
delta_label: str = "vs prev period"
) -> str:
"""
Create HTML for a KPI tile.
Args:
title: KPI title
value: Main value to display
format_type: Format type from KPI_FORMATS
delta: Optional change value
delta_label: Label for delta
Returns:
HTML string
"""
fmt = KPI_FORMATS.get(format_type, "{}")
try:
if value is None:
formatted_value = "N/A"
elif pd.isna(value):
formatted_value = "N/A"
else:
formatted_value = fmt.format(float(value))
except (ValueError, TypeError):
formatted_value = str(value) if value is not None else "N/A"
delta_html = ""
if delta is not None and not pd.isna(delta):
try:
delta_val = float(delta)
delta_color = "#10B981" if delta_val >= 0 else "#EF4444"
delta_symbol = "▲" if delta_val >= 0 else "▼"
delta_html = f'<div style="color: {delta_color}; font-size: 14px; margin-top: 4px;">{delta_symbol} {abs(delta_val):.1f}% {delta_label}</div>'
except (ValueError, TypeError):
pass
html = f"""
<div style="
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 24px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
min-height: 120px;
">
<div style="font-size: 14px; opacity: 0.9; margin-bottom: 8px; font-weight: 500;">{title}</div>
<div style="font-size: 32px; font-weight: 700; margin-bottom: 4px;">{formatted_value}</div>
{delta_html}
</div>
"""
return html
def create_kpi_grid(kpis: List[Dict[str, Any]]) -> str:
"""
Create a grid of KPI tiles.
Args:
kpis: List of dicts with keys: title, value, format_type, delta (optional)
Returns:
HTML grid
"""
tiles = [create_kpi_tile(**kpi) for kpi in kpis]
grid_html = f"""
<div style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
">
{''.join(tiles)}
</div>
"""
return grid_html
# =============================================================================
# CHART COMPONENTS
# =============================================================================
def create_empty_figure(message: str = "No data available") -> go.Figure:
"""Create an empty figure with a message."""
fig = go.Figure()
fig.add_annotation(
text=message,
xref="paper", yref="paper",
x=0.5, y=0.5, showarrow=False,
font=dict(size=16, color="#9CA3AF")
)
fig.update_layout(
template='plotly_white',
height=400,
xaxis=dict(visible=False),
yaxis=dict(visible=False)
)
return fig
def create_line_chart(
df: pd.DataFrame,
x_col: str,
y_col: str,
title: str,
x_label: str = "",
y_label: str = "",
color_col: Optional[str] = None
) -> go.Figure:
"""Create a line chart with Plotly."""
if df is None or df.empty or x_col not in df.columns or y_col not in df.columns:
return create_empty_figure("No data available for this period")
try:
if color_col and color_col in df.columns:
fig = px.line(
df, x=x_col, y=y_col, color=color_col,
title=title,
labels={x_col: x_label, y_col: y_label},
color_discrete_sequence=COLOR_PALETTE
)
else:
fig = px.line(
df, x=x_col, y=y_col,
title=title,
labels={x_col: x_label, y_col: y_label},
color_discrete_sequence=[COLOR_PALETTE[0]]
)
fig.update_layout(
hovermode='x unified',
template='plotly_white',
font=dict(size=12),
title_font_size=16,
height=400,
margin=dict(l=40, r=40, t=60, b=40)
)
fig.update_traces(line=dict(width=2.5))
return fig
except Exception as e:
logger.error(f"Error creating line chart: {e}")
return create_empty_figure(f"Error creating chart: {str(e)}")
def create_bar_chart(
df: pd.DataFrame,
x_col: str,
y_col: str,
title: str,
x_label: str = "",
y_label: str = "",
orientation: str = "v"
) -> go.Figure:
"""Create a bar chart with Plotly."""
if df is None or df.empty or x_col not in df.columns or y_col not in df.columns:
return create_empty_figure("No data available")
try:
fig = px.bar(
df, x=x_col, y=y_col,
title=title,
labels={x_col: x_label, y_col: y_label},
orientation=orientation,
color_discrete_sequence=[COLOR_PALETTE[0]]
)
fig.update_layout(
template='plotly_white',
font=dict(size=12),
title_font_size=16,
height=400,
margin=dict(l=40, r=40, t=60, b=40)
)
return fig
except Exception as e:
logger.error(f"Error creating bar chart: {e}")
return create_empty_figure(f"Error creating chart: {str(e)}")
def create_pie_chart(
df: pd.DataFrame,
names_col: str,
values_col: str,
title: str
) -> go.Figure:
"""Create a pie chart with Plotly."""
if df is None or df.empty or names_col not in df.columns or values_col not in df.columns:
return create_empty_figure("No data available")
try:
fig = px.pie(
df, names=names_col, values=values_col,
title=title,
color_discrete_sequence=COLOR_PALETTE
)
fig.update_traces(
textposition='inside',
textinfo='percent+label',
hovertemplate='%{label}: %{value:,.0f}<br>%{percent}'
)
fig.update_layout(
template='plotly_white',
font=dict(size=12),
title_font_size=16,
height=400,
margin=dict(l=40, r=40, t=60, b=40),
showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=-0.2)
)
return fig
except Exception as e:
logger.error(f"Error creating pie chart: {e}")
return create_empty_figure(f"Error creating chart: {str(e)}")
def create_heatmap(
df: pd.DataFrame,
x_col: str,
y_col: str,
z_col: str,
title: str,
x_label: str = "",
y_label: str = ""
) -> go.Figure:
"""Create a heatmap with Plotly."""
if df is None or df.empty:
return create_empty_figure("No data available")
try:
# Pivot data for heatmap
pivot_df = df.pivot(index=y_col, columns=x_col, values=z_col)
fig = px.imshow(
pivot_df,
title=title,
labels=dict(x=x_label, y=y_label, color=z_col),
color_continuous_scale='Blues',
aspect="auto"
)
fig.update_layout(
template='plotly_white',
font=dict(size=12),
title_font_size=16,
height=400
)
return fig
except Exception as e:
logger.error(f"Error creating heatmap: {e}")
return create_empty_figure(f"Error creating heatmap: {str(e)}")
def create_geo_heatmap(
df: pd.DataFrame,
lat_col: str = "latitude",
lon_col: str = "longitude",
size_col: Optional[str] = None,
hover_data: Optional[List[str]] = None,
title: str = "Geographic Distribution"
) -> go.Figure:
"""Create a geographic heat map using scatter_mapbox."""
if df is None or df.empty:
return create_empty_figure("No geographic data available")
if lat_col not in df.columns or lon_col not in df.columns:
return create_empty_figure(f"Missing required columns: {lat_col}, {lon_col}")
# Remove null coordinates
df_clean = df.dropna(subset=[lat_col, lon_col]).copy()
if df_clean.empty:
return create_empty_figure("No valid coordinates found")
try:
# Determine center
center_lat = df_clean[lat_col].median()
center_lon = df_clean[lon_col].median()
# Filter hover_data to only include columns that exist
if hover_data:
hover_data = [col for col in hover_data if col in df_clean.columns]
if not hover_data:
hover_data = None
# Create map
fig = px.scatter_mapbox(
df_clean,
lat=lat_col,
lon=lon_col,
size=size_col if size_col and size_col in df_clean.columns else None,
hover_data=hover_data,
title=title,
color_continuous_scale='Reds',
zoom=3
)
fig.update_layout(
mapbox_style=MAP_STYLE,
mapbox_center={"lat": center_lat, "lon": center_lon},
template='plotly_white',
height=500,
font=dict(size=12),
title_font_size=16,
margin=dict(l=0, r=0, t=50, b=0)
)
if MAPBOX_TOKEN:
fig.update_layout(mapbox_accesstoken=MAPBOX_TOKEN)
return fig
except Exception as e:
logger.error(f"Error creating geo heatmap: {e}")
return create_empty_figure(f"Error creating map: {str(e)}")
def create_density_heatmap(
df: pd.DataFrame,
lat_col: str = "latitude",
lon_col: str = "longitude",
z_col: Optional[str] = None,
title: str = "Heat Map"
) -> go.Figure:
"""Create a density heat map."""
if df is None or df.empty:
return create_empty_figure("No data available")
if lat_col not in df.columns or lon_col not in df.columns:
return create_empty_figure("Missing coordinate columns")
df_clean = df.dropna(subset=[lat_col, lon_col]).copy()
if df_clean.empty:
return create_empty_figure("No valid coordinates")
try:
center_lat = df_clean[lat_col].median()
center_lon = df_clean[lon_col].median()
fig = px.density_mapbox(
df_clean,
lat=lat_col,
lon=lon_col,
z=z_col if z_col and z_col in df_clean.columns else None,
radius=10,
title=title,
zoom=3,
mapbox_style=MAP_STYLE
)
if MAPBOX_TOKEN:
fig.update_layout(mapbox_accesstoken=MAPBOX_TOKEN)
fig.update_layout(
mapbox_center={"lat": center_lat, "lon": center_lon},
template='plotly_white',
height=500,
font=dict(size=12),
title_font_size=16,
margin=dict(l=0, r=0, t=50, b=0)
)
return fig
except Exception as e:
logger.error(f"Error creating density heatmap: {e}")
return create_empty_figure(f"Error creating heatmap: {str(e)}")
# =============================================================================
# TABLE COMPONENTS
# =============================================================================
def create_data_table(
df: pd.DataFrame,
title: str = "",
max_rows: int = 100
) -> str:
"""Create an HTML table from DataFrame."""
if df is None or df.empty:
return f"""
<div style="padding: 20px; text-align: center;">
<h3 style="margin-bottom: 16px; color: #374151;">{title}</h3>
<p style="color: #9CA3AF;">No data available</p>
</div>
"""
# Limit rows
df_display = df.head(max_rows).copy()
# Format numbers
for col in df_display.select_dtypes(include=['float64', 'float32']).columns:
df_display[col] = df_display[col].apply(
lambda x: f"{x:,.2f}" if pd.notnull(x) else ""
)
table_html = df_display.to_html(index=False, classes='dataframe', border=0)
styled_html = f"""
<div style="padding: 16px;">
<h3 style="margin-bottom: 16px; color: #374151; font-size: 18px; font-weight: 600;">{title}</h3>
<div style="max-height: 400px; overflow-y: auto; border-radius: 8px; border: 1px solid #e5e7eb;">
<style>
.dataframe {{
border-collapse: collapse;
width: 100%;
font-size: 14px;
}}
.dataframe th {{
background-color: #6366f1;
color: white;
padding: 12px 16px;
text-align: left;
position: sticky;
top: 0;
z-index: 10;
font-weight: 600;
}}
.dataframe td {{
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}}
.dataframe tr:hover {{
background-color: #f9fafb;
}}
.dataframe tr:nth-child(even) {{
background-color: #f9fafb;
}}
</style>
{table_html}
</div>
"""
if len(df) > max_rows:
styled_html += f'<p style="margin-top: 8px; color: #6B7280; font-size: 12px;"><em>Showing {max_rows} of {len(df)} rows</em></p>'
styled_html += "</div>"
return styled_html
# =============================================================================
# EXPORT HELPERS
# =============================================================================
def df_to_csv(df: pd.DataFrame, filename: str = "export.csv") -> Optional[str]:
"""Convert DataFrame to CSV string for download."""
if df is None or df.empty:
return None
try:
return df.to_csv(index=False)
except Exception as e:
logger.error(f"Error converting to CSV: {e}")
return None