Figure Friday 2025 - week 34

join the Figure Friday session on August 29, at noon Eastern Time, to showcase your creation and receive feedback from the community.

What type of train incidents occur within the Montreal metro system?

Answer this question and a few others by using Plotly on the Montreal Metro Incidents dataset.

Things to consider:

  • what can you improve in the app or sample figure below (bar chart)?
  • would you like to tell a different data story using a different graph or Dash app?
  • how can you explore the data with Plotly Studio?

Sample figure:

Code for sample figure:
from dash import Dash, dcc
import dash_ag_grid as dag
import plotly.express as px
import pandas as pd

# download CSV sheet from Google Drive - https://drive.google.com/file/d/1hlH3wEzeMCmdPVTIjajrnFpszHzHwG5r/view
df = pd.read_csv('Incidents-du-reseau-du-metro.csv')
df['Heure de reprise'] = pd.to_datetime(df['Heure de reprise'], format='%H:%M', errors='coerce')
df["Heure de l'incident"] = pd.to_datetime(df["Heure de l'incident"], format='%H:%M', errors='coerce')

# Get time lapsed between incident start and end time
df['Incident_Timeframe'] = df["Heure de reprise"]- df["Heure de l'incident"]
# Get the total time in minutes
df['Incident_duration_minutes'] = df['Incident_Timeframe'].dt.total_seconds() / 60
# Filter the dataframe for incidents happening in Stations, not on trains
df_filtered = df[df["Type d'incident"] == 'S']

fig = px.histogram(df_filtered, x="Code de lieu", y="Incident_duration_minutes", histfunc='avg',
                   title="Average Incident Duration by Station")
fig.update_xaxes(tickangle=45)
fig.layout.update(margin=dict(l=20, r=20, t=30, b=30))


grid = dag.AgGrid(
    rowData=df.to_dict("records"),
    columnDefs=[{"field": i, 'filter': True, 'sortable': True} for i in df.columns],
    dashGridOptions={"pagination": True},
    # columnSize="sizeToFit"
)

app = Dash()
app.layout = [
    grid,
    dcc.Graph(figure=fig)
]


if __name__ == "__main__":
    app.run(debug=False)

For community members that would like to build the data app with Plotly Studio, but don’t have the application yet, simply go to Plotly.com/studio. Please keep in mind that Plotly Studio is still in early access.

Below is a screenshot of a heatmap built by Plotly Studio on top of this dataset:

Participation Instructions:

  • Create - use the weekly data set to build your own Plotly visualization or Dash app. Or, enhance the sample figure provided in this post, using Plotly or Dash.
  • Submit - post your creation to LinkedIn or Twitter with the hashtags #FigureFriday and #plotly by midnight Thursday, your time zone. Please also submit your visualization as a new post in this thread.
  • Celebrate - join the Figure Friday sessions to showcase your creation and receive feedback from the community.

:point_right: If you prefer to collaborate with others on Discord, join the Plotly Discord channel.

Data Source:

Thank you to Montreal Open Data portal for the data.

3 Likes

Hi,

Hmm
 French. I see you provided the data definitions (both column titles and contents for row options), definitely helpful. I could use some help to make sure I’m going down the the correct path. Am I correct in assuming the Python script should have a dictionary for translating to English? Or is there some neat Python library that does this?

Thanks,
Mike

Good question, @mike_finko . I’ve never tried translating, but I think you can use the Google API for that:

from deep_translator import GoogleTranslator

DeepL also has an api for translation.

Ok, interesting, I’ll look into it, thanks!

Hi Mike ,

This code may helps you avoiding rework :winking_face_with_tongue:

Code to Translate to English

import pandas as pd
import numpy as np

def translate_montreal_metro_data(df):
“”"
Translate Montreal Metro incidents dataset from French to English
“”"

# Create a copy to avoid modifying the original
df_translated = df.copy()

# Column name translations (French -> English, pythonic style)
column_translations = {
    "Numero d'incident": "incident_number",
    "Type d'incident": "incident_type", 
    "Cause primaire": "primary_cause",
    "Cause secondaire": "secondary_cause",
    "Symptome": "symptom",
    "Ligne": "line",
    "Numéro de tournée": "run_number",
    "Heure de l'incident": "incident_time",
    "Heure de reprise": "recovery_time", 
    "Incident en minutes": "incident_duration_minutes",
    "Véhicule": "vehicle",
    "Porte de voiture": "car_door",
    "Type de matériel": "material_type",
    "Code de lieu": "location_code",
    "Dommage matériel": "material_damage",
    "KFS": "kfs",  # Emergency brake usage
    "Porte": "door",
    "Urgence métro": "metro_emergency",
    "CAT": "cat",  # Traction power cut
    "Évacuation": "evacuation",
    "Année civile": "calendar_year",
    "Année civile/mois": "calendar_year_month", 
    "Mois calendrier": "calendar_month",
    "Jour du mois": "day_of_month",
    "Jour de la semaine": "day_of_week",
    "Jour calendaire": "calendar_day"
}

# Rename columns
df_translated.rename(columns=column_translations, inplace=True)

# Content translations for categorical variables

# Incident Type translations
incident_type_map = {
    'T': 'Train',
    'S': 'Station'
}
if 'incident_type' in df_translated.columns:
    df_translated['incident_type'] = df_translated['incident_type'].map(incident_type_map).fillna(df_translated['incident_type'])

# Primary Cause translations
primary_cause_map = {
    'Clients': 'Customers',
    'ClientĂšle': 'Customers',
    'Matériel roulant': 'Material on wheels', 
    'Équipement fixe': 'Fixed equipment',
    'Équipements fixes': 'Fixed equipment',
    'Exploitation des trains': 'Train operations',
    'Exploitation trains': 'Train operations',
    'Autre': 'Other',
    'Autres': 'Other'
}
if 'primary_cause' in df_translated.columns:
    df_translated['primary_cause'] = df_translated['primary_cause'].map(primary_cause_map).fillna(df_translated['primary_cause'])

# Secondary Cause translations  
secondary_cause_map = {
    'Personne blessée ou malade': 'Injured or sick person',
    'Blessée ou malade': 'Injured or sick person',
    'Méfait intentionnel': 'Intentional mischief',
    'Méfait volontaire': 'Intentional mischief',
    'Nuisance non intentionnelle': 'Unintentional nuisance',
    'Nuisance involontaire': 'Unintentional nuisance',
    'Clients': 'Customers',
    'ClientĂšle': 'Customers',
    'MPM-10': 'MPM-10',
    'MR-73': 'MR-73', 
    'Véhicule de travail': 'Work vehicle',
    'Véhicules de travaux': 'Work vehicles',
    'Service voie': 'Track service',
    'Service de la voie': 'Track service',
    'Service train': 'Train service',
    'Service aux trains': 'Train service',
    'Service station': 'Station service',
    'Service aux stations': 'Station service',
    'Service TCPE': 'TCPE service',
    'TCPE service': 'TCPE service',
    'Équipement fixe': 'Fixed equipment',
    'Équipements fixes': 'Fixed equipment',
    'Matériel roulant': 'Material on wheels',
    'Ligne 1,2,4,5': 'Line 1,2,4,5',
    'Ligne 1, 2, 4, 5': 'Line 1, 2, 4, 5',
    'Poste de commande': 'Control center',
    'Centre de contrĂŽle': 'Control center',
    'Exploitation des trains': 'Train operations',
    'Exploitation trains': 'Train operations',
    'Causes externes': 'External causes',
    'External causes': 'External causes',
    'Contrats Réno-Stations': 'Réno-Stations contracts',
    'Réno-Stations contracts': 'Réno-Stations contracts',
    'Contrats Réno-SystÚme': 'Réno-System contracts',
    'Réno-System contracts': 'Réno-System contracts',
    'Contrats MPM-10': 'MPM-10 contracts',
    'Contrats MPM10': 'MPM-10 contracts',
    'Personnel/équipement STM': 'STM staff/equipment',
    'Pers. / Équipement STM': 'STM staff/equipment',
    'Personnel/équipement externe': 'External staff/equipment',
    'Pers. / Équipement Externe': 'External staff/equipment',
    'Autre': 'Other',
    'Autres': 'Other',
    'Other': 'Other'
}
if 'secondary_cause' in df_translated.columns:
    df_translated['secondary_cause'] = df_translated['secondary_cause'].map(secondary_cause_map).fillna(df_translated['secondary_cause'])

# Symptom translations
symptom_map = {
    'Clients': 'Customers',
    'ClientĂšle': 'Customers',
    'Matériel roulant': 'Material on wheels',
    'Équipement fixe': 'Fixed equipment',
    'Équipements fixes': 'Fixed equipment',
    'Exploitation des trains': 'Train operations',
    'Exploitation trains': 'Train operations',
    'Exploitation stations': 'Station operations',
    'Feu, fumée, odeur, substance, etc.': 'Fire, smoke, odor, substance, etc.',
    'Feu, fumée, odeur, produit, etc...': 'Fire, smoke, odor, substance, etc.',
    'Divers': 'Miscellaneous'
}
if 'symptom' in df_translated.columns:
    df_translated['symptom'] = df_translated['symptom'].map(symptom_map).fillna(df_translated['symptom'])

# Line translations (handle complex combinations)
line_map = {
    '1': 'Green line',
    '2': 'Orange line', 
    '4': 'Yellow line',
    '5': 'Blue line',
    'Ligne verte': 'Green line',
    'Ligne orange': 'Orange line',
    'Ligne jaune': 'Yellow line',
    'Ligne bleue': 'Blue line',
    'Ligne 1, 2, 4, 5': 'Lines 1, 2, 4, 5',
    'Ligne 1, 2, 4': 'Lines 1, 2, 4',
    'Ligne 1, 4': 'Lines 1, 4',
    'Ligne 1, 2': 'Lines 1, 2',
    'Ligne 5, 2': 'Lines 5, 2',
    'Ligne 2, 5': 'Lines 2, 5',
    'Ligne 2, 1': 'Lines 2, 1',
    'Ligne 4, 1': 'Lines 4, 1',
    'Ligne 1, 2, 5': 'Lines 1, 2, 5',
    'Ligne 2, 1, 4': 'Lines 2, 1, 4',
    'Ligne 2, 1, 5': 'Lines 2, 1, 5',
    'Ligne 4, 2, 1': 'Lines 4, 2, 1',
    'Ligne 4, 1, 2': 'Lines 4, 1, 2',
    'Ligne 5, 1': 'Lines 5, 1',
    'Ligne 2, 4': 'Lines 2, 4',
    'Ligne 1, 5': 'Lines 1, 5',
    'Non affecté': 'Not affected'
}
if 'line' in df_translated.columns:
    df_translated['line'] = df_translated['line'].astype(str).map(line_map).fillna(df_translated['line'])

# Material type translations
material_type_map = {
    '73': 'MR-73',
    '10': 'MPM-10',
    'Non affecté': 'Not affected'
}
if 'material_type' in df_translated.columns:
    df_translated['material_type'] = df_translated['material_type'].astype(str).map(material_type_map).fillna(df_translated['material_type'])

# Evacuation translations
evacuation_map = {
    'Voiture': 'Car',
    'Train': 'Train', 
    'Station': 'Station',
    'Station et train': 'Station and train',
    'Train et Station': 'Train and Station',
    '#': 'No evacuation'
}
if 'evacuation' in df_translated.columns:
    df_translated['evacuation'] = df_translated['evacuation'].map(evacuation_map).fillna(df_translated['evacuation'])

# Day of week translations (assuming numeric 1-7 or French names)
day_of_week_map = {
    'Lundi': 'Monday',
    'Mardi': 'Tuesday', 
    'Mercredi': 'Wednesday',
    'Jeudi': 'Thursday',
    'Vendredi': 'Friday',
    'Samedi': 'Saturday',
    'Dimanche': 'Sunday',
    1: 'Monday',
    2: 'Tuesday',
    3: 'Wednesday', 
    4: 'Thursday',
    5: 'Friday',
    6: 'Saturday',
    7: 'Sunday'
}
if 'day_of_week' in df_translated.columns:
    df_translated['day_of_week'] = df_translated['day_of_week'].map(day_of_week_map).fillna(df_translated['day_of_week'])

# Incident duration translations
duration_map = {
    '02 min et moins': '2 min or less',
    '03 Ă  04 min': '3 to 4 min',
    '05 Ă  09 min': '5 to 9 min',
    '10 Ă  14 min': '10 to 14 min',
    '15 Ă  19 min': '15 to 19 min',
    '20 Ă  24 min': '20 to 24 min',
    '25 Ă  29 min': '25 to 29 min',
    '30 min et plus': '30 min or more'
}
if 'incident_duration_minutes' in df_translated.columns:
    df_translated['incident_duration_minutes'] = df_translated['incident_duration_minutes'].map(duration_map).fillna(df_translated['incident_duration_minutes'])

# Calendar year/month translations
month_map = {
    'janv': 'Jan', 'févr': 'Feb', 'mars': 'Mar', 'avr': 'Apr',
    'mai': 'May', 'juin': 'Jun', 'juil': 'Jul', 'août': 'Aug',
    'sept': 'Sep', 'oct': 'Oct', 'nov': 'Nov', 'déc': 'Dec'
}

if 'calendar_year_month' in df_translated.columns:
    # Function to translate month abbreviations
    def translate_month_year(value):
        if pd.isna(value):
            return value
        for french_month, english_month in month_map.items():
            if french_month in str(value):
                return str(value).replace(french_month, english_month)
        return value
    
    df_translated['calendar_year_month'] = df_translated['calendar_year_month'].apply(translate_month_year)

return df_translated

def save_translated_data(df_translated, output_filename=‘montreal_metro_incidents_english.csv’):
“”"
Save the translated dataframe to CSV
“”"
df_translated.to_csv(output_filename, index=False, encoding=‘utf-8’)
print(f"Translated data saved to {output_filename}")
return output_filename

Usage example:

def main():
# Load your French dataset
df_french = pd.read_csv(‘Incidents-du-reseau-du-metro.csv’)

# Translate the data
df_english = translate_montreal_metro_data(df_french)

# Save translated data
save_translated_data(df_english)

# Display basic info about translated dataset
print("Translated Dataset Info:")
print(df_english.info())
print("\nFirst few rows:")
print(df_english.head())

pass

if name == “main”:
main()

Additional utility function to display translation mappings

def show_translation_mappings():
“”"
Display all translation mappings for reference
“”"
print(“COLUMN NAME TRANSLATIONS:”)
print(“-” * 50)
column_translations = {
“Numero d’incident”: “incident_number”,
“Type d’incident”: “incident_type”,
“Cause primaire”: “primary_cause”,
“Cause secondaire”: “secondary_cause”,
“Symptome”: “symptom”,
“Ligne”: “line”,
“NumĂ©ro de tournĂ©e”: “run_number”,
“Heure de l’incident”: “incident_time”,
“Heure de reprise”: “recovery_time”,
“Incident en minutes”: “incident_duration_minutes”,
“VĂ©hicule”: “vehicle”,
“Porte de voiture”: “car_door”,
“Type de matĂ©riel”: “material_type”,
“Code de lieu”: “location_code”,
“Dommage matĂ©riel”: “material_damage”,
“KFS”: “kfs”,
“Porte”: “door”,
“Urgence mĂ©tro”: “metro_emergency”,
“CAT”: “cat”,
“Évacuation”: “evacuation”,
“AnnĂ©e civile”: “calendar_year”,
“AnnĂ©e civile/mois”: “calendar_year_month”,
“Mois calendrier”: “calendar_month”,
“Jour du mois”: “day_of_month”,
“Jour de la semaine”: “day_of_week”,
“Jour calendaire”: “calendar_day”
}

for french, english in column_translations.items():
    print(f"{french:25} -> {english}")

print("\nCONTENT TRANSLATIONS SUMMARY:")
print("-" * 50)
print("✓ Incident types: T/S -> Train/Station")
print("✓ Primary causes: French terms -> English equivalents") 
print("✓ Secondary causes: French terms -> English equivalents")
print("✓ Symptoms: French terms -> English equivalents")
print("✓ Lines: 1,2,4,5 -> Green/Orange/Yellow/Blue line")
print("✓ Material types: 73/10 -> MR-73/MPM-10")
print("✓ Evacuation types: French terms -> English equivalents")
print("✓ Days of week: French/numeric -> English names")
1 Like

Hello Everyone for this FF Week 34 answer this question
What type of train incidents occur within the Montreal metro system?

I have created an interactive dashboard to analyze incidents on the Montreal subway system. The goal is to provide information that is as granular and detailed as possible, which can generate clear insights for decision-making.

Key Features

The dashboard is distinguished by its multiple functionalities:

  • Prediction: It uses a statistical model, based on NumPy, to predict incidents with weighted averages and adjustments for rush hours.
  • Temporal Analysis: It employs dynamic visualizations, such as a heatmap and monthly trend charts, to identify patterns and seasonality.
  • Operational Metrics: It provides essential data on incidents, such as average duration, cumulative duration, and the count of expected incidents.
  • Cause and Effect Analysis: It helps to identify the main causes of incidents and critical points within stations, providing a ranking of the most problematic ones.

Any questions/doubt/suggestion do not hesitate to ask

The code

import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
import dash
from dash import dcc, html, Input, Output, callback
import dash_bootstrap_components as dbc
import warnings
warnings.filterwarnings(‘ignore’)

Load data. Ensure ‘montreal_metro_incidents_english.csv’ is in the same directory.

df = pd.read_csv(“montreal_metro_incidents_english.csv”).copy()
df[‘incident_time’] = pd.to_datetime(df[‘incident_time’], errors=‘coerce’)
df[‘calendar_day’] = pd.to_datetime(df[‘calendar_day’])
df[‘hour’] = df[‘incident_time’].dt.hour
df[‘calendar_month’] = df[‘calendar_day’].dt.month
df[‘calendar_year’] = df[‘calendar_day’].dt.year
df[‘day_of_week’] = df[‘calendar_day’].dt.day_name()

Define time ranges

def get_time_range(hour):
if pd.isna(hour):
return “Unknown”
elif 5 <= hour <= 7:
return “Early Morning (5-7 AM)”
elif 8 <= hour <= 10:
return “Morning (8-10 AM)”
elif 11 <= hour <= 13:
return “Midday (11 AM-1 PM)”
elif 14 <= hour <= 16:
return “Afternoon (2-4 PM)”
elif 17 <= hour <= 19:
return “Evening Rush (5-7 PM)”
elif 20 <= hour <= 22:
return “Night (8-10 PM)”
return “Late Night/Early Morning”
df[‘time_range’] = df[‘hour’].apply(get_time_range)

Convert recovery time to minutes for duration analysis

if ‘recovery_time’ in df.columns and ‘incident_time’ in df.columns:
df[‘recovery_time’] = pd.to_datetime(df[‘recovery_time’], errors=‘coerce’)
duration_diff = (df[‘recovery_time’] - df[‘incident_time’]).dt.total_seconds() / 60
df[‘calculated_duration_minutes’] = pd.to_numeric(duration_diff, errors=‘coerce’).fillna(15)
else:
df[‘calculated_duration_minutes’] = np.nan
print(“Warning: The ‘recovery_time’ column was not found in the data. Duration time calculation will not be available.”)

NEW NUMPY-BASED PREDICTION FUNCTION

def predict_incidents_numpy(line, start_hour, end_hour):
“”"
NumPy-based prediction using weighted historical averages
Now with DATA-DRIVEN rush hour analysis instead of arbitrary assumptions
“”"
# Filter historical data for exact conditions
filtered = df[
(df[‘line’] == line) &
(df[‘hour’] >= start_hour) &
(df[‘hour’] <= end_hour) &
(df[‘hour’].notna())
]

if filtered.empty:
    return 0

# Group by calendar day and count incidents
daily_counts = filtered.groupby(filtered['calendar_day'].dt.date).size().values

if len(daily_counts) == 0:
    return 0

# Apply weighted average (more weight to recent data)
weights = np.linspace(0.5, 1.0, len(daily_counts))

# Calculate weighted average
if len(weights) > 0:
    weighted_avg = np.average(daily_counts, weights=weights)
    
    # Apply DATA-DRIVEN adjustment factor based on real historical patterns
    data_driven_factor = calculate_rush_factor_data_driven(line, start_hour, end_hour)
    
    # Final prediction
    prediction = weighted_avg * data_driven_factor
    
    return max(0, round(prediction))

return 0

def calculate_rush_factor_data_driven(line, start_hour, end_hour):
“”"
Calculate rush hour factor based on REAL historical data patterns
More accurate than arbitrary rush hour definitions
“”"
# Get all incidents for this specific line
line_data = df[df[‘line’] == line].copy()

if line_data.empty:
    return 1.0

# Calculate average incidents per hour for this line
hourly_incidents = line_data.groupby('hour').size()

if len(hourly_incidents) == 0:
    return 1.0

# Calculate overall average incidents per hour for this line
overall_avg = hourly_incidents.mean()

# Calculate the average incident rate for our selected time range
selected_hours = range(start_hour, end_hour + 1)
selected_hours_data = hourly_incidents.reindex(selected_hours, fill_value=0)
selected_avg = selected_hours_data.mean()

# Factor is the ratio: selected_range_average / overall_average
# If > 1.0: selected range has more incidents than average (rush factor)
# If < 1.0: selected range has fewer incidents than average (quiet factor)
if overall_avg > 0:
    factor = selected_avg / overall_avg
    # Limit factor between 0.5 and 2.0 to avoid extreme values
    return max(0.5, min(2.0, factor))

return 1.0

def calculate_prediction_confidence(line, start_hour, end_hour):
“”"
Calculate confidence level based on data availability
“”"
filtered = df[
(df[‘line’] == line) &
(df[‘hour’] >= start_hour) &
(df[‘hour’] <= end_hour)
]

unique_days = filtered['calendar_day'].dt.date.nunique()

if unique_days >= 30:
    return "High"
elif unique_days >= 10:
    return "Medium"
elif unique_days >= 3:
    return "Low"
else:
    return "Very Low"

Initialize Dash app with SLATE theme for better styling

app = dash.Dash(name, external_stylesheets=[dbc.themes.SLATE])

app.title=“Montreal Metro Dashboard”

Main layout with improved styling

app.layout = dbc.Container([
# Header with gradient background
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H1(“:metro: Montreal Metro - Dynamic Dashboard”,
className=“text-center mb-2 text-white display-4”),
html.H5(“Enhanced with NumPy-based Smart Predictions”,
className=“text-center text-light mb-0”),
])
], style={“background”: “linear-gradient(135deg, #495057 0%, #6c757d 100%)”,
“border”: “none”}, className=“mb-4”)
])
]),

# Narrative Section with card styling
dbc.Row([
    dbc.Col([
        dbc.Card([
            dbc.CardHeader([
                html.H4("📊 Dashboard Overview", className="mb-0 text-light")
            ], style={"background-color": "#495057"}),
            dbc.CardBody([
                dcc.Markdown(
                    """
                    A dashboard for the Montreal Metro uses data science to analyze incidents.
                    The NumPy-based prediction system uses historical data and rush-hour adjustments
                    to provide reliable incident forecasting with confidence indicators.
                    """,
                    className="mb-0"
                )
            ])
        ], className="mb-4 shadow-sm border-0")
    ])
]),

# Interactive Controls in card format
dbc.Row([
    dbc.Col([
        dbc.Card([
            dbc.CardHeader([
                html.H4("🎯 Interactive Controls", className="text-white mb-0")
            ], style={"background-color": "#495057"}),
            dbc.CardBody([
                dbc.Row([
                    dbc.Col([
                        html.Label("⏰ Time Range Selection:", className="fw-bold mb-3 text-light"),
                        dcc.RangeSlider(
                            id='hour-range-slider',
                            min=0,
                            max=23,
                            step=1,
                            value=[8, 18],
                            marks={i: {'label': f'{i:02d}h', 'style': {'writing-mode': 'vertical-lr', 'text-orientation': 'sideways-right', 'color': '#adb5bd'}} for i in range(0, 24, 3)},
                            tooltip={"placement": "bottom", "always_visible": True}
                        )
                    ], width=7),
                    dbc.Col([
                        html.Label("🚇 Metro Line Selection:", className="fw-bold mb-2 text-light"),
                        dcc.Dropdown(
                            id='line-dropdown',
                            options=[{'label': f"Line {line}", 'value': line} for line in sorted(df['line'].unique())],
                            value=sorted(df['line'].unique())[0],
                            className="mb-3",
                            style={"border-radius": "8px"}
                        )
                    ], width=5)
                ])
            ], style={"background-color": "#343a40"})
        ], className="mb-4 shadow")
    ])
]),

# Key Metrics Cards with better styling
dbc.Row([
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("📈 Incident Prediction", className="text-center bg-primary text-white"),
            dbc.CardBody(id="prediction-output", style={"background": "linear-gradient(145deg, #1e3a5f 0%, #2c5282 100%)"})
        ], className="h-100 shadow-sm border-0")
    ], width=3),
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("🎯 Confidence Level", className="text-center bg-success text-white"),
            dbc.CardBody(id="confidence-output", style={"background": "linear-gradient(145deg, #1e3a2e 0%, #2f5233 100%)"})
        ], className="h-100 shadow-sm border-0")
    ], width=3),
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("⏳ Avg Duration", className="text-center bg-info text-white"),
            dbc.CardBody(id="duration-time-output", style={"background": "linear-gradient(145deg, #1e3a3a 0%, #2c5f5f 100%)"})
        ], className="h-100 shadow-sm border-0")
    ], width=3),
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("⏱ Total Duration", className="text-center bg-warning text-white"),
            dbc.CardBody(id="total-duration-output", style={"background": "linear-gradient(145deg, #3a3a1e 0%, #5f5f2c 100%)"})
        ], className="h-100 shadow-sm border-0")
    ], width=3)
], className="mb-4"),

# Visualizations Section in Cards
dbc.Row([
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("đŸ”„ Incident Patterns Analysis", className="bg-danger text-white text-center"),
            dbc.CardBody([
                dcc.Graph(id="heatmap-dynamic")
            ], className="p-1")
        ], className="shadow-sm border-0 h-100")
    ], width=6),
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("📈 Temporal Trends", className="bg-secondary text-white text-center"),
            dbc.CardBody([
                dcc.Graph(id="monthly-trends-dynamic")
            ], className="p-1")
        ], className="shadow-sm border-0 h-100")
    ], width=6)
], className="mb-4"),

# Distribution Analysis in Cards
dbc.Row([
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("🔍 Cause Analysis", className="bg-dark text-white text-center"),
            dbc.CardBody([
                dcc.Graph(id="duration-by-cause-dynamic")
            ], className="p-1")
        ], className="shadow-sm border-0 h-100")
    ], width=6),
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("⚠ Symptom Distribution", className="bg-primary text-white text-center"),
            dbc.CardBody([
                dcc.Graph(id="incident-symptoms-dynamic")
            ], className="p-1")
        ], className="shadow-sm border-0 h-100")
    ], width=6)
], className="mb-4"),

# Station Analysis in Card
dbc.Row([
    dbc.Col([
        dbc.Card([
            dbc.CardHeader("🚉 Station Impact Analysis", className="bg-info text-white text-center"),
            dbc.CardBody([
                dcc.Graph(id="incidents-by-station-dynamic")
            ], className="p-1")
        ], className="shadow-sm border-0")
    ])
])

], fluid=True, style={“background-color”: “#2c3e50”, “min-height”: “100vh”, “padding”: “20px 0”})

Callback to update metric cards with enhanced data-driven insights

@app.callback(
[Output(“prediction-output”, “children”),
Output(“confidence-output”, “children”),
Output(“duration-time-output”, “children”),
Output(“total-duration-output”, “children”)],
[Input(“hour-range-slider”, “value”),
Input(“line-dropdown”, “value”)]
)
def update_cards(hour_range, line):
start_hour, end_hour = hour_range
prediction_rounded = predict_incidents_numpy(line, start_hour, end_hour)

confidence = calculate_prediction_confidence(line, start_hour, end_hour)

# Logic to calculate duration metrics for cards
filtered_df = df[
    (df['line'] == line) &
    (df['hour'] >= start_hour) &
    (df['hour'] <= end_hour)
]

avg_duration = "N/A"
total_duration = "N/A"

if not filtered_df.empty:
    if 'calculated_duration_minutes' in filtered_df.columns:
        durations = filtered_df['calculated_duration_minutes'].dropna()
        if not durations.empty:
            avg_duration = f"{durations.mean():.1f} min"
            
            # Calculate total duration in hours and minutes
            total_minutes = int(durations.sum())
            hours = total_minutes // 60
            minutes = total_minutes % 60
            
            total_duration = f"{hours}h {minutes}m"

prediction_card = html.Div([
    html.H1(f"{prediction_rounded}", className="text-center mb-0 text-white"),
    html.P("incidents expected", className="text-center text-light small")
])

confidence_card = html.Div([
    html.H1(confidence, className="text-center mb-0 text-white"),
    html.P("prediction confidence", className="text-center text-light small")
])

duration_card = html.Div([
    html.H1(avg_duration, className="text-center mb-0 text-white"),
    html.P("average duration", className="text-center text-light small")
])

total_duration_card = html.Div([
    html.H1(total_duration, className="text-center mb-0 text-white"),
    html.P("total cummulative", className="text-center text-light small")
])

return prediction_card, confidence_card, duration_card, total_duration_card

Callback for dynamic heatmap

@app.callback(
Output(“heatmap-dynamic”, “figure”),
[Input(“hour-range-slider”, “value”),
Input(“line-dropdown”, “value”)]
)
def update_heatmap_dynamic(hour_range, line):
start_hour, end_hour = hour_range
filtered_df = df[(df[‘hour’] >= start_hour) & (df[‘hour’] <= end_hour) & (df[‘line’] == line)]

pivot_hour_day = filtered_df.groupby(['day_of_week', 'hour']).size().reset_index(name='count')
pivot_table = pivot_hour_day.pivot(index='day_of_week', columns='hour', values='count').fillna(0)

weekday_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
pivot_table = pivot_table.reindex(weekday_order)

fig = px.imshow(pivot_table,
                title=f'đŸ”„ Incident Heatmap: Hour vs. Day of Week (Line {line})',
                labels={'x': 'Hour', 'y': 'Day of Week', 'color': 'Incidents'},
                color_continuous_scale='Reds',
                aspect='auto')
fig.update_layout(height=500)
fig.update_xaxes(dtick=1, tickmode='linear')

return fig

Callback for dynamic monthly trends chart

@app.callback(
Output(“monthly-trends-dynamic”, “figure”),
[Input(“hour-range-slider”, “value”),
Input(“line-dropdown”, “value”)]
)
def update_monthly_trends_dynamic(hour_range, line):
start_hour, end_hour = hour_range
filtered_df = df[(df[‘hour’] >= start_hour) & (df[‘hour’] <= end_hour) & (df[‘line’] == line)]

monthly_incidents = filtered_df.groupby(['calendar_year', 'calendar_month']).size().reset_index(name='incidents')
monthly_incidents['year_month'] = monthly_incidents['calendar_year'].astype(str) + '-' + monthly_incidents['calendar_month'].astype(str).str.zfill(2)

fig = px.line(monthly_incidents, x='year_month', y='incidents',
              title=f'📈 Monthly Incident Trends (Line {line}, {start_hour}-{end_hour}h)',
              template='xgridoff', markers=True)
fig.update_xaxes(tickangle=45)
fig.update_layout(height=500)

return fig

Callback for the main cause pie chart

@app.callback(
Output(“duration-by-cause-dynamic”, “figure”),
[Input(“hour-range-slider”, “value”),
Input(“line-dropdown”, “value”)]
)
def update_duration_by_cause_dynamic(hour_range, line):
start_hour, end_hour = hour_range
filtered_df = df[
(df[‘hour’] >= start_hour) &
(df[‘hour’] <= end_hour) &
(df[‘line’] == line)
]

if 'primary_cause' in filtered_df.columns and not filtered_df.empty:
    cause_counts = filtered_df['primary_cause'].value_counts().reset_index()
    cause_counts.columns = ['primary_cause', 'count']
    fig = px.pie(
        cause_counts.nlargest(10, 'count'),
        values='count',
        names='primary_cause',
        hole=0.65,
        title=f"📊 Incident Distribution by Cause (Line {line}, {start_hour}-{end_hour}h)"
    )
    fig.update_traces(textinfo='percent+label', insidetextorientation='radial')
    fig.update_layout(height=400, showlegend=False)

    return fig

return go.Figure()

Callback for the incidents by station chart

@app.callback(
Output(“incidents-by-station-dynamic”, “figure”),
[Input(“hour-range-slider”, “value”),
Input(“line-dropdown”, “value”)]
)
def update_incidents_by_station_dynamic(hour_range, line):
start_hour, end_hour = hour_range
filtered_df = df[
(df[‘hour’] >= start_hour) &
(df[‘hour’] <= end_hour) &
(df[‘line’] == line)
]

if 'location_code' in filtered_df.columns and not filtered_df.empty:
    station_counts = filtered_df['location_code'].value_counts().reset_index()
    station_counts.columns = ['station_name', 'count']
    fig = px.bar(
        station_counts.nlargest(10, 'count'),
        x='station_name',
        y='count',
        title=f"📊 Incidents by Station for Line {line} ({start_hour}-{end_hour}h)",
        labels={'station_name': 'Station', 'count': 'Number of Incidents'},
        template='xgridoff',
        text_auto=True
    )
    fig.update_layout(height=400)
    fig.update_yaxes(visible=False, showticklabels=False)
    return fig

return go.Figure()

Callback for the symptoms pie chart

@app.callback(
Output(“incident-symptoms-dynamic”, “figure”),
[Input(“hour-range-slider”, “value”),
Input(“line-dropdown”, “value”)]
)
def update_incident_symptoms_dynamic(hour_range, line):
start_hour, end_hour = hour_range
filtered_df = df[(df[‘hour’] >= start_hour) & (df[‘hour’] <= end_hour) & (df[‘line’] == line)]

if 'symptom' in filtered_df.columns and not filtered_df.empty:
    symptom_counts = filtered_df['symptom'].value_counts().reset_index()
    symptom_counts.columns = ['symptom', 'count']
    fig = px.pie(symptom_counts, values='count', names='symptom', hole=0.65,
                 title=f"📊 Symptom Distribution for Line {line} ({start_hour}-{end_hour}h)",
                 labels={'symptom': 'Symptom', 'count': 'Number of Incidents'})
    fig.update_traces(textinfo='percent+label', insidetextorientation='radial')
    fig.update_layout(height=400, showlegend=False)
    return fig

return go.Figure()

server = app.server

3 Likes

Awesome app, @Avacsiglo21 .

I chose the orange line from 6am-6pm. It’s interesting to see how most incidents at 8am happen on Monday and Tuesday and then they slowly decrease in frequency as the week goes by. Is this limited to train or station incidents or both?

These two pie charts are also very telling.

You know what an interesting analysis would be?!
We have Symptom which is the initial attribute of the incident reason; and then we have cause which is incident reason after investigation. It would be interesting to see one chart that highlights the change in incident reason before and after investigation.

2 Likes

I did a prompt trying to recreate the dash app in plotly look at the result :rofl: :rofl: ItÂŽs not exactly the same but I like the results

The Prompt

# Title
Montreal Metro Incidents Analysis
# Description
Interactive data app analyzing Montreal Metro incident patterns, causes, and recovery times with predictive analytics and comprehensive filtering capabilities for operational insights.
# Charts
1.
Incident frequency heatmap by hour and day of week as a heatmap with dropdowns for metro line selection (all unique lines) and time range sliders (0-23 hours)
2.
Monthly incident trends over time as a line chart with dropdowns for metro line filtering (all lines) and incident type selection (Station, Train)
3.
Primary cause distribution as a donut chart with multi-dropdown for metro line filtering (all unique lines) and checklist for incident type (Station, Train)
4.
Symptom category breakdown as a pie chart with dropdowns for line selection (all lines) and duration range sliders (2 min or less to 30 min or more)
5.
Top stations by incident count as a horizontal bar chart with multi-dropdown for line filtering (all unique lines) and range inputs for time period selection
6.
Incident duration patterns by time of day as a box plot with dropdowns for metro line (all lines) and primary cause filtering (all unique causes)
# Global Filters
1.
Year with dropdown (2019 through 2025)
2.
Metro Line with multi-dropdown (all unique lines including Green line, Orange line, Blue line, Yellow line)
3.
Incident Type with checklist (Station, Train)
4.
Primary Cause with multi-dropdown (Other, Customers, Fixed equipment, Material on wheels, Train operations)
5.
Incident Duration with dropdown (2 min or less through 30 min or more)
6.
Day of Week with multi-dropdown (Monday through Sunday)
7.
Month with range input (1 to 12)
8.
Evacuation Status with dropdown (No evacuation, Train, Station, Car, Train and Station)
9.
Material Damage with checklist (Yes, No)
# Theme
Montreal Metro transit theme with dark navy and slate color scheme emphasizing urban transportation aesthetics, clean technical typography, and gradient card headers for operational dashboard clarity
# Other Details
Enhanced with NumPy-based smart predictions using weighted historical averages and data-driven rush hour factors for incident forecasting

2 Likes

This is a great idea,

1 Like

In this case both

1 Like

I had an idea, and got stuck. I ended wondering why mapbox is deprecated and alternative created maps look more like fractal art.

Fractal art with scattergeo:

Fast backward, the image and the app.

Datacleaning

  • some 40+ years ago, I had dutch, english, german and french at school. My french is worst but good enough to not think about translating. All these languages become a bit fluid. The germans are very proud of their language, we, the dutch want to belong.
  • I removed all incidents which could not be assigned to exactly 1 station or had empty values for primary cause, location or duration of the incident itself.
  • I tried to improve station names like Berri-UQAM with addition of the line. They may be called the same but 
. one try, one thought, “whatever, you’ve spent enough time on FF, or actually on maps and stations“ .

The code reflects all these changes of plan. :grinning_face:

2 Likes

Love the art, @marieanne . I’m not sure I completely understand what this bubble is telling us.
The Berri-UQAM station has different colors that correspond to various shades of blue, but what is grouping this incidents? Is this time; does every shade represent a year?

2 Likes

Hi Adams,

As requested, I added a bar chart (Initial Symptoms vs Final Causes)and re-order the pie charts Symptoms vs Causes, is not perfect but helps

3 Likes

Well, that happens when you’re too obsessed with a map, it’s plain wrong, thank you for noticing. I did not.:sweat_smile:

29/08: fixed it.

3 Likes

That bar chart is really helpful. Thank you, @Avacsiglo21 . I didn’t realize the change rate is 50% That’s really high.

1 Like

Hi All. I want to share my simple dashboard app for this Metro incidents data. open for your opinion about the design. Thank you

7 Likes

Wow is Beatiful your app. Is just an infografic or dashboard?

1 Like

Hi all, I’m working on some cross-clicking between charts.

This project is an interactive dashboard for visualizing Montreal Metro incident data. Built with Dash, Plotly, and AG Grid, it features dynamic charts. Users can filter incidents by year, month, metro line, and location, and explore trends by cause, time, and place. The dashboard uses a modern dark theme and includes a metro icon for a professional look. Additionally, I implemented cross-filtering between the charts, allowing users to click on chart elements (such as pie slices or heatmap cells) to dynamically filter the data across the entire dashboard.
I think this dashboard is mobile-friendly:)

This is my app: Dash

Code
import dash
from dash import dcc, html, Input, Output, State, no_update
import dash_bootstrap_components as dbc
import pandas as pd
import plotly.graph_objects as go
import calendar

# --- Load and preprocess data ---
df = pd.read_csv("incidents.csv")
df["Calendar Day"] = pd.to_datetime(df["Calendar Day"], errors="coerce")
df["Incident Time"] = pd.to_datetime(
    df["Incident Time"], format="%H:%M:%S", errors="coerce"
).dt.hour

days_map = {
    1: "Monday",
    2: "Tuesday",
    3: "Wednesday",
    4: "Thursday",
    5: "Friday",
    6: "Saturday",
    7: "Sunday",
}
df["Day Name"] = df["Day of Week"].map(days_map)

year_options = sorted(df["Calendar Year"].dropna().unique())
month_options = [
    {"label": calendar.month_name[i], "value": i}
    for i in range(1, 13)
]
line_options = sorted(df["Line"].dropna().unique())
location_options = sorted(df["Location Code"].dropna().unique())

def make_select(id, options, placeholder):
    return dbc.Col(
        dbc.Select(
            id=id,
            options=options,
            value=None,
            placeholder=placeholder,
            style={
                "backgroundColor": "#222",
                "color": "white",
                "borderColor": "#444",
            },
        ),
        xs=12, sm=6, md=3, lg=3, xl=2,
        className="mb-2"
    )

def info_button(id):
    return html.Span(
        [
            html.I(
                className="bi bi-info-circle-fill",
                id=id,
                style={
                    "cursor": "pointer",
                    "color": "#fff",
                    "marginLeft": "8px",
                    "fontSize": "1.2em"
                }
            ),
        ]
    )

def kpi_card(title, value):
    return dbc.Card(
        dbc.CardBody(
            [
                html.H5(title, className="card-title text-center", style={"color": "white", "marginBottom": "8px"}),
                html.H2(value, className="card-text text-center", style={"color": "white", "margin": "0"}),
            ]
        ),
        color="dark",
        inverse=True,
        className="mb-3",
        style={"minHeight": "120px", "backgroundColor": "#222", "border": "none"},
    )

app = dash.Dash(
    __name__,
    external_stylesheets=[
        dbc.themes.DARKLY,
        "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"
    ]
)

server = app.server


app.layout = dbc.Container(
    [
        # --- Title and Reset button in one row ---
        dbc.Row(
            [
                dbc.Col(
                    html.H2("Montreal Metro Incidents Dashboard 🚇", className="text-center", style={"color": "white"}),
                    xs=8, sm=8, md=8, lg=10, xl=10,
                    style={"display": "flex", "alignItems": "center"},
                ),
                dbc.Col(
                    dbc.Button(
                        "Reset",
                        id="reset-btn",
                        color="success",
                        outline=False,
                        size="sm",
                        style={
                            "backgroundColor": "#28a745",
                            "color": "white",
                            "border": "none",
                            "fontWeight": "bold",
                            "boxShadow": "0 2px 6px rgba(0,0,0,0.15)",
                            "marginBottom": "8px",
                            "marginTop": "8px",
                            "float": "right",
                        },
                    ),
                    xs=4, sm=4, md=4, lg=2, xl=2,
                    style={"display": "flex", "justifyContent": "flex-end", "alignItems": "center"},
                ),
            ],
            className="mb-2",
            style={"alignItems": "center"},
        ),
        dbc.Row(
            [
                dbc.Col(id="kpi-total", xs=12, sm=6, md=3, lg=3, xl=3),
                dbc.Col(id="kpi-lines", xs=12, sm=6, md=3, lg=3, xl=3),
                dbc.Col(id="kpi-locations", xs=12, sm=6, md=3, lg=3, xl=3),
                dbc.Col(id="kpi-most-cause", xs=12, sm=6, md=3, lg=3, xl=3),
            ],
            className="mb-4 justify-content-center",
        ),
        dbc.Row(
            [
                make_select("year-filter", [{"label": str(y), "value": str(y)} for y in year_options], "Year"),
                make_select("month-filter", month_options, "Month"),
                make_select("line-filter", [{"label": str(l), "value": str(l)} for l in line_options], "Line"),
                make_select("location-filter", [{"label": str(l), "value": str(l)} for l in location_options], "Location"),
            ],
            className="mb-3 justify-content-center flex-wrap",
        ),
        dbc.Row(
            [
                dbc.Col(
                    html.Div([
                        info_button("info-line"),
                        dbc.Popover(
                            "Shows the number of incidents per day. Click a day to filter.",
                            target="info-line",
                            body=True,
                            trigger="hover",
                            placement="right",
                        ),
                        dcc.Graph(id="line-chart", style={"height": "350px"}),
                    ]),
                    xs=12, sm=12, md=6, lg=6, xl=6,
                ),
                dbc.Col(
                    html.Div([
                        info_button("info-pie"),
                        dbc.Popover(
                            "Shows the top 5 primary causes as a pie chart. Click a slice to filter.",
                            target="info-pie",
                            body=True,
                            trigger="hover",
                            placement="left",
                        ),
                        dcc.Graph(id="pie-chart", style={"height": "350px"}),
                    ]),
                    xs=12, sm=12, md=6, lg=6, xl=6,
                ),
            ],
            className="mb-2 justify-content-center flex-wrap",
        ),
        dbc.Row([
            dbc.Col(
                html.Div([
                    info_button("info-heatmap"),
                    dbc.Popover(
                        "Shows incidents by day of week and hour. Click a cell to filter.",
                        target="info-heatmap",
                        body=True,
                        trigger="hover",
                        placement="right",
                    ),
                    dcc.Graph(id="heatmap", style={"height": "350px"}),
                ]),
                xs=12, sm=12, md=6, lg=6, xl=6,
            ),
            dbc.Col(
                html.Div([
                    info_button("info-location"),
                    dbc.Popover(
                        "Shows top 10 locations by incident count. Click a bar to filter.",
                        target="info-location",
                        body=True,
                        trigger="hover",
                        placement="right",
                    ),
                    dcc.Graph(id="location-chart", style={"height": "350px"}),
                ]),
                xs=12, sm=12, md=6, lg=6, xl=6,
            ),
        ], className="mb-2"),
        html.Div(id="last-click", style={"display": "none"}),
    ],
    fluid=True,
    style={"backgroundColor": "#111", "padding": "32px"},
)

@app.callback(
    Output("year-filter", "value"),
    Output("month-filter", "value"),
    Output("line-filter", "value"),
    Output("location-filter", "value"),
    Output("last-click", "children"),
    Input("reset-btn", "n_clicks"),
    Input("line-chart", "clickData"),
    Input("pie-chart", "clickData"),
    Input("heatmap", "clickData"),
    Input("location-chart", "clickData"),
    State("last-click", "children"),
    prevent_initial_call=True,
)
def handle_all_events(reset_click, line_click, pie_click, heatmap_click, location_click, prev_last_click):
    ctx = dash.callback_context
    if not ctx.triggered:
        return no_update, no_update, no_update, no_update, no_update

    trigger = ctx.triggered[0]["prop_id"].split(".")[0]

    if trigger == "reset-btn":
        return None, None, None, None, ""

    if trigger == "line-chart" and line_click:
        point = line_click["points"][0]
        return no_update, no_update, no_update, no_update, f"line:{point['x']}"
    elif trigger == "pie-chart" and pie_click:
        point = pie_click["points"][0]
        return no_update, no_update, no_update, no_update, f"pie:{point['label']}"
    elif trigger == "heatmap" and heatmap_click:
        point = heatmap_click["points"][0]
        return no_update, no_update, no_update, no_update, f"heatmap:{point['y']}:{point['x']}"
    elif trigger == "location-chart" and location_click:
        point = location_click["points"][0]
        return no_update, no_update, no_update, no_update, f"location:{point['x']}"

    return no_update, no_update, no_update, no_update, prev_last_click

@app.callback(
    Output("line-chart", "figure"),
    Output("pie-chart", "figure"),
    Output("heatmap", "figure"),
    Output("location-chart", "figure"),
    Output("kpi-total", "children"),
    Output("kpi-lines", "children"),
    Output("kpi-locations", "children"),
    Output("kpi-most-cause", "children"),
    Input("year-filter", "value"),
    Input("month-filter", "value"),
    Input("line-filter", "value"),
    Input("location-filter", "value"),
    Input("last-click", "children"),
)
def update_charts_and_kpis(year, month, line, location, last_click):
    dff = df.copy()
    filter_info = ""
    if year:
        dff = dff[dff["Calendar Year"] == int(year)]
        filter_info += f"Year: {year} "
    if month:
        dff = dff[dff["Calendar Month"] == int(month)]
        filter_info += f"Month: {calendar.month_name[int(month)]} "
    if line:
        dff = dff[dff["Line"] == line]
        filter_info += f"Line: {line} "
    if location:
        dff = dff[dff["Location Code"] == location]
        filter_info += f"Location: {location} "
    if last_click:
        if last_click.startswith("line:"):
            daystr = last_click.split(":", 1)[1]
            dff = dff[dff["Calendar Day"].dt.strftime("%Y-%m-%d") == daystr]
            filter_info += f"Date: {daystr} "
        elif last_click.startswith("pie:"):
            cause = last_click.split(":", 1)[1]
            dff = dff[dff["Primary Cause"] == cause]
            filter_info += f"Primary Cause: {cause} "
        elif last_click.startswith("heatmap:"):
            _, day_name, hour = last_click.split(":")
            dff = dff[(dff["Day Name"] == day_name) & (dff["Incident Time"] == int(hour))]
            filter_info += f"Day: {day_name}, Hour: {hour} "
        elif last_click.startswith("location:"):
            loc = last_click.split(":", 1)[1]
            dff = dff[dff["Location Code"] == loc]
            filter_info += f"Location: {loc} "

    # --- KPI values ---
    total_incidents = len(dff)
    unique_lines = dff["Line"].nunique()
    unique_locations = dff["Location Code"].nunique()
    most_cause = dff["Primary Cause"].mode()[0] if not dff.empty and "Primary Cause" in dff.columns else "N/A"

    # --- Line chart: incidents by year-month-day ---
    dff["DateStr"] = dff["Calendar Day"].dt.strftime("%Y-%m-%d")
    date_order = sorted(dff["DateStr"].unique())
    line_data = (
        dff.groupby("DateStr")
        .size()
        .reindex(date_order, fill_value=0)
        .reset_index()
    )
    line_fig = go.Figure()
    line_fig.add_trace(
        go.Scatter(
            x=line_data["DateStr"],
            y=line_data[0] if 0 in line_data.columns else line_data[1],
            mode="lines+markers",
            line=dict(color="yellow", width=3, shape="spline"),
            marker=dict(color="yellow", size=6),
            name="Incidents",
        )
    )
    line_fig.update_layout(
        title="Incidents by Date" + (f" | {filter_info}" if filter_info else ""),
        template="plotly_dark",
        font_color="white",
        margin=dict(t=40, b=20, l=10, r=10),
        height=350,
        xaxis=dict(showticklabels=True, title=None, tickangle=45),
        yaxis=dict(title=None),
    )

    # --- Pie chart: top 5 Primary Cause, yellow gradient, darkest for largest ---
    pie_data = (
        dff.groupby("Primary Cause")
        .size()
        .reset_index(name="Incidents")
        .sort_values("Incidents", ascending=False)
        .head(5)
    )
    yellow_gradient = ["#FF2A00", "#FFC300", "#FFD700", "#FFE066", "#FFF9B0"]
    colors = [yellow_gradient[i] for i in range(len(pie_data))]
    pie_fig = go.Figure(
        data=[
            go.Pie(
                labels=pie_data["Primary Cause"],
                values=pie_data["Incidents"],
                hole=0.6,
                marker=dict(colors=colors),
                textinfo="none"
            )
        ]
    )
    pie_fig.update_layout(
        title="Top 5 Primary Causes" + (f" | {filter_info}" if filter_info else ""),
        template="plotly_dark",
        font_color="white",
        margin=dict(t=40, b=20, l=10, r=10),
        height=350,
    )

    # --- Heatmap: Day Name vs Hour ---
    heatmap_data = (
        dff.groupby(["Day Name", "Incident Time"])
        .size()
        .reset_index(name="Count")
    )
    all_days = [
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
        "Sunday",
    ]
    all_hours = list(range(0, 24))
    heatmap_matrix = (
        heatmap_data.pivot(index="Day Name", columns="Incident Time", values="Count")
        .reindex(index=all_days, columns=all_hours, fill_value=0)
    )
    custom_colorscale = [
        [0.0, "green"],
        [0.33, "yellow"],
        [0.66, "orange"],
        [1.0, "red"],
    ]
    heatmap_fig = go.Figure(
        data=go.Heatmap(
            z=heatmap_matrix.values,
            x=heatmap_matrix.columns,
            y=heatmap_matrix.index,
            colorscale=custom_colorscale,
            colorbar=dict(title="Incidents"),
        )
    )
    heatmap_fig.update_layout(
        title="Incidents Heatmap (Day Name vs. Hour)" + (f" | {filter_info}" if filter_info else ""),
        template="plotly_dark",
        font_color="white",
        margin=dict(t=40, b=20, l=10, r=10),
        height=350,
        yaxis=dict(autorange="reversed"),
    )

    # --- Location chart: Top 10 locations ---
    location_data = (
        dff.groupby("Location Code")
        .size()
        .reset_index(name="Incidents")
        .sort_values("Incidents", ascending=False)
        .head(10)
    )
    location_fig = go.Figure(
        data=[
            go.Bar(
                x=location_data["Location Code"],
                y=location_data["Incidents"],
                marker=dict(color="#FBFF00"),
            )
        ]
    )
    location_fig.update_layout(
        title="Top 10 Locations by Incidents" + (f" | {filter_info}" if filter_info else ""),
        template="plotly_dark",
        font_color="white",
        margin=dict(t=40, b=20, l=10, r=10),
        height=350,
        xaxis=dict(showticklabels=True, title=None),
        yaxis=dict(title=None),
    )

    # --- KPI cards ---
    kpi_total = kpi_card("Total Incidents", total_incidents)
    kpi_lines = kpi_card("Unique Lines", unique_lines)
    kpi_locations = kpi_card("Unique Locations", unique_locations)
    kpi_most_cause = kpi_card("Most Frequent Primary Cause", most_cause)

    return line_fig, pie_fig, heatmap_fig, location_fig, kpi_total, kpi_lines, kpi_locations, kpi_most_cause

if __name__ == "__main__":
    app.run(debug=True)

This is the Plotly Studio version in mobile view:

5 Likes

amazing. What libraries did you use aside from Dash, @waliyudin ?
Did you use a lot of CSS? and are you willing to share the code?

1 Like

Good luck, @Ester .
Do you plan to have all graphs interact with each other or just one of them will update the others?

1 Like