|
|
""" |
|
|
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__) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"] |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_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}") |
|
|
|
|
|
|
|
|
df_clean = df.dropna(subset=[lat_col, lon_col]).copy() |
|
|
|
|
|
if df_clean.empty: |
|
|
return create_empty_figure("No valid coordinates found") |
|
|
|
|
|
try: |
|
|
|
|
|
center_lat = df_clean[lat_col].median() |
|
|
center_lon = df_clean[lon_col].median() |
|
|
|
|
|
|
|
|
if hover_data: |
|
|
hover_data = [col for col in hover_data if col in df_clean.columns] |
|
|
if not hover_data: |
|
|
hover_data = None |
|
|
|
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
""" |
|
|
|
|
|
|
|
|
df_display = df.head(max_rows).copy() |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |