Figure Friday 2025 - week 11

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

Did US hurricane categories strengthen over time? Any trends in the naming of hurricanes?

We will try to answer these and many other questions using the dataset on US Hurricanes.

We recommend cleaning the data before fully exploring it:

  • the early names of hurricanes are wrapped by quotation marks, which should probably be removed
  • the states in the third column could be separated
  • the third column also has a few meaningful symbols. You can read more about them in the Notes section at the bottom of the NOAA webpage.

Things to consider:

  • what can you improve in the app or sample figure below (bubble chart)?
  • would you like to tell a different data story using a different graph?
  • can you create a different Dash app?
  • can you use an LLM for the hurricane names analysis?

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

df = pd.read_csv('https://raw.githubusercontent.com/plotly/Figure-Friday/refs/heads/main/2025/week-11/us-hurricanes.csv')

# Convert the row value of `TS` under the `category` column to a Nan
df['category'] = df['category'].replace('TS', pd.NA)

fig = px.scatter(df, x='max-wind-(kt)', y='central-pressure-(mb)', hover_name='year',
                 color='category', title='US Hurricanes', height=675)
fig.update_traces(marker_size=15)

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)

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 NOAA’s HurricaneReserach Division for the data.

2 Likes

Not every experiment has a happy end.

What it should be:

How it ends at py.cafe.

The grid appears once and disappears forever. I see a few errors with the development tools, but nothing except version, I have a problem solving idea for. I must admit that ChatGPT was a bit of a help but also this morning I thought “it’s drunk”. If there is , and there could easily be, an error in the mapping from hurricane to state affected, I have not checked and would normally do. There is a bit too much on my plate right now to dive in deeper.

3 Likes

Hi @marieanne
That’s weird how Dash AG Grid appears and then quickly disappears. I couldn’t spot the bug yet, but I might have time to look more into it after the Dash 3.0 post I’m writing.

On load online there are already 2 error messages, and after clicking a marker it looks like this (and I wonder if it is about dash versions). @home it’s errorfree.

Hello everyone!


My Dash app cleans and preprocesses a NOAA-based hurricane dataset, performs some feature engineering (like computed categories and decade groupings), and displays multiple interactive charts. I also included a trend analysis for strong hurricanes (Category 4 & 5) and explored naming patterns over time.

Feel free to check out the code on GitHub: US-hurricane repository
Kaggle: https://www.kaggle.com/code/feanor92/us-hurricane

Would love to hear your thoughts and feedback!

3 Likes

The US Hurricanes Analysis visualizes hurricane trends over time. The bar chart shows the number of hurricanes per year, with notable spikes in activity during certain periods like the late 1800s, 1940s, and 2000s. The heatmap illustrates hurricane frequency by decade and category, highlighting higher occurrences of stronger hurricanes in specific decades. Overall, the dashboard provides insights into long-term patterns of hurricane activity in the United States.

4 Likes

He @fean

Your link to Kaggle is dead and I do not see the project in your projectlist.
In the github code are many more figures, so I was curious to see what they do.

Hi Eszter,

I like the use of the heatmap here.

1 Like

Marianne,

Have you tried with the map alone, and then testing the combination map with the table, or map with the chart. I do not know may be this can give you an idea

1 Like

That’s a good idea @avacsiglo21, since the map is the enigine, I just excluded the chart, which means only the grid should appear on click and the grid behaves the same. You click for the first time on a marker, the grid flickers and disappears. After that when clicking nothing happens.

As I mentioned earlier, locally it works perfect.

1 Like

Hello Everyone,

Here is my Figure Friday Dashboard. This is a brief description of the WebApp/Dashboard:

  1. Data Filtering: The hurricane data can be filtered by year range using a slider and by selecting specific US states from a dropdown menu.
  2. Data Visualizations:
  • A polar chart displaying hurricane intensity by plotting wind speed (angle) against pressure (distance from the center), with point size representing hurricane category. Since this type of chart may be challenging to understand, an information button is included to explain how to interpret the visualization.
  • A geographic map displaying hurricane distribution across affected states, with color coding for categories and size indicating wind speed.
  • Statistics showing the total number of hurricanes, average wind speed, average central pressure, and category distribution for the selected filters.
  1. Predictive Analysis: A seasonal hurricane probability chart displaying historical hurricane frequency by month, showing both bar graph data and a trend line.


The code

import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from dash import Dash, dcc, html, Input, Output, callback, State
import dash_bootstrap_components as dbc

df = pd.read_csv("us-hurricanes.csv").rename(columns={"states-affected-and-category-by-states":"states_affected"}).dropna()
df['category'] = df['category'].replace('TS', pd.NA)
df['states_affected'] = df.states_affected.str.split(",", expand=True)[0]

def clean_state(state):
    state = state.replace('* ', '').replace('# ', '').replace('& ', '').replace('&', '').replace(' 1', '').replace(' - TS', '').upper()
    return state

df['states_affected'] = df['states_affected'].apply(clean_state)

# Coordenadas de los estados
state_coords = {
    'FL': [27.6648, -81.5158],
    'AL': [32.3182, -86.9023],
    'GA': [33.0406, -83.6431],
    'TX': [31.9686, -99.9018],
    'LA': [31.1695, -91.8678],
    'NY': [42.6648, -74.0158],
    'NC': [35.7596, -79.0193],
    'RI': [41.7001, -71.4221],
    'ME': [45.2538, -69.4455],
    'SC': [33.8361, -81.1637],
    'MA': [42.4072, -71.3824]
}


app = Dash(__name__, external_stylesheets=[dbc.themes.LUMEN])

app.title="Hurricane Dashboard"

# App Layout
app.layout = dbc.Container([
    dbc.Row([
        dbc.Col([
            html.H1("US-Hurricane Dashboard", className="text-center bg-primary text-white p-3 mb-4"),
            html.P("Hurricane Data Visualization", className="text-center mb-4")
        ])
    ]),
    dbc.Row([
        dbc.Col([
            dbc.Card([
                dbc.CardHeader("Filters"),
                dbc.CardBody([
                    dbc.Row([
                        dbc.Col([
                            html.Label("Years Range:"),
                            dcc.RangeSlider(
                                id='year-range-slider',
                                min=df['year'].min(),
                                max=df['year'].max(),
                                value=[df['year'].min(), df['year'].max()],
                                marks={i: str(i) for i in range(df['year'].min(), df['year'].max() + 1, 10)},
                                step=5
                            )
                        ], width=6),
                        dbc.Col([
                            html.Label("State:"),
                            dcc.Dropdown(
                                id='state-dropdown',
                                options=[{'label': 'All States', 'value': 'all'}] +
                                        [{'label': state, 'value': state} for state in state_coords.keys()],
                                value='all',
                                clearable=False
                            )
                        ], width=6)
                    ])
                ])
            ], className="mb-4")
        ])
    ]),
    dbc.Row([
        dbc.Col([
            dbc.Card([
                dbc.CardHeader([
                    dbc.Row([
                        dbc.Col("Wind Speed vs. Pressure Polar Chart", width=9),
                        dbc.Col([
                            dbc.Button(
                                [html.I(className="fas fa-info-circle me-1"), "How to read this chart"],
                                id="polar-info-button",
                                color="info",
                                size="sm",
                                className="float-end"
                            ),
                        ], width=3),
                    ]),
                ]),
                dbc.CardBody([
                    dcc.Graph(id='polar-chart', style={'height': '500px'}),
                    dbc.Collapse(
                        dbc.Card(
                            dbc.CardBody([
                                html.H6("How to Interpret the Polar Chart:", className="card-title"),
                                html.Ul([
                                    html.Li([
                                        html.Strong("Angle (Theta): "), 
                                        "Represents the normalized wind speed. Higher angles indicate higher wind speeds relative to the minimum and maximum in the dataset."
                                    ]),
                                    html.Li([
                                        html.Strong("Distance from Center (Radius): "), 
                                        "Represents the inverted pressure value (1000 - pressure). The further from center, the lower the hurricane's pressure."
                                    ]),
                                    html.Li([
                                        html.Strong("Point Size: "), 
                                        "Increases with hurricane category - larger points indicate higher category hurricanes."
                                    ]),
                                    html.Li([
                                        html.Strong("Color: "), 
                                        "Different colors represent different hurricane categories, as shown in the legend."
                                    ]),
                                    html.Li([
                                        html.Strong("Interpretation: "), 
                                        "The most intense hurricanes (higher categories) typically appear larger, further from center (lower pressure), and at higher angles (higher wind speeds)."
                                    ])])
                            ]), className="mt-2 border-info"),
                        id="polar-info-collapse",
                        is_open=False,)])])
        ], width=6),
        dbc.Col([
            dbc.Card([
                dbc.CardHeader("Geographic Distribution of Hurricanes"),
                dbc.CardBody([
                    dcc.Graph(id='map-chart', style={'height': '500px'})
                ])])], width=6)
    ]),
    dbc.Row([
        dbc.Col([
            dbc.Card([
                dbc.CardHeader("Hurricane Information"),
                dbc.CardBody([
                    html.Div(id='hurricane-details', className="p-3")
                ])
            ], className="mt-4")])]),
    dbc.Row([
        dbc.Col([
            dbc.Card([
                dbc.CardHeader("Predictive Analysis"),
                dbc.CardBody([
                    html.H5("Seasonal Hurricane Probability"),
                    html.P("Based on historical patterns of the selected filters"),
                    dcc.Graph(id='prediction-chart')
                ])
            ], className="mt-4")])]),
    
    # Adding FontAwesome for the Icons
    html.Link(
        rel="stylesheet",
        href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css")
], fluid=True)

# Callback information Button
@app.callback(
    Output("polar-info-collapse", "is_open"),
    [Input("polar-info-button", "n_clicks")],
    [State("polar-info-collapse", "is_open")],
)
def toggle_collapse(n, is_open):
    if n:
        return not is_open
    return is_open

# Callback update the chart
@app.callback(
    [Output('polar-chart', 'figure'),
     Output('map-chart', 'figure'),
     Output('hurricane-details', 'children'),
     Output('prediction-chart', 'figure')],
    [Input('year-range-slider', 'value'),
     Input('state-dropdown', 'value')]
)
def update_charts(year_range, state):
    # Filtrar los datos
    filtered_df = df
    filtered_df = filtered_df[(filtered_df['year'] >= year_range[0]) & (filtered_df['year'] <= year_range[1])]
    if state != 'all':
        filtered_df = filtered_df[filtered_df['states_affected'].str.contains(state)]

    # Gráfico polar
    polar_fig = go.Figure()
    
    if not filtered_df.empty:
        for cat in sorted(filtered_df['category'].dropna().unique()):
            cat_df = filtered_df[filtered_df['category'] == cat]
            if not cat_df.empty:
                max_wind = cat_df['max-wind-(kt)'].max()
                min_wind = cat_df['max-wind-(kt)'].min()
                if max_wind > min_wind:
                    theta = ((cat_df['max-wind-(kt)'] - min_wind) / (max_wind - min_wind)) * 360
                else:
                    theta = cat_df['max-wind-(kt)'] * 0 + 180
                radius = 1000 - cat_df['central-pressure-(mb)']
                color_map = {str(c): px.colors.qualitative.Plotly[i % len(px.colors.qualitative.Plotly)]
                            for i, c in enumerate(sorted(filtered_df['category'].dropna().unique()))}
                size_map = {str(c): (i + 1) * 5 for i, c in enumerate(sorted(filtered_df['category'].dropna().unique()))}
                polar_fig.add_trace(
                    go.Scatterpolar(r=radius, theta=theta, mode='markers', 
                                    marker=dict(size=size_map[cat], 
                                                color=color_map[cat], opacity=0.7), 
                                    name=f'Category {cat}', 
                                    text=cat_df['name'].fillna('Unnamed') + '<br>' + 'Year: ' + cat_df['year'].astype(str) + '<br>' + 'Pressure: ' + cat_df['central-pressure-(mb)'].astype(str) + ' mb<br>' + 'Wind: ' + cat_df['max-wind-(kt)'].astype(str) + ' kt', hoverinfo='text'))
    
    polar_fig.update_layout(
        polar=dict(radialaxis=dict(visible=True, range=[0, 100]),
                   angularaxis=dict(direction='clockwise')),
        title={
            'text': 'Hurricane Intensity Chart',
            'y': 0.95,
            'x': 0.5,
            'xanchor': 'center',
            'yanchor': 'top'
        },
        annotations=[
            dict(
                text="Click the info button above for help interpreting this chart",
                showarrow=False,
                xref="paper", yref="paper",
                x=0.5, y=-0.1,
                font=dict(size=10, color="gray")
            )
        ],
        showlegend=True)

    # Gráfico del mapa
    map_fig = go.Figure()
    
    if not filtered_df.empty:
        lats = []
        lons = []
        for state_str in filtered_df['states_affected']:
            primary_state = state_str.split(',')[0]
            if primary_state in state_coords:
                lats.append(state_coords[primary_state][0])
                lons.append(state_coords[primary_state][1])
            else:
                lats.append(30.0)
                lons.append(-85.0)
                print(f"Warning: State '{primary_state}' not found in state_coords. Usando coordenadas predeterminadas.")

        if len(lats) == len(filtered_df) and len(lons) == len(filtered_df):
            map_df = filtered_df.copy()
            map_df['lat'] = lats
            map_df['lon'] = lons

            map_fig = px.scatter_mapbox(map_df, lat='lat', lon='lon', color='category', 
                                        size='max-wind-(kt)', size_max=20, zoom=3, 
                                        center=dict(lat=30, lon=-85), 
                                        hover_name='name', hover_data=['year', 'month', 'central-pressure-(mb)', 'max-wind-(kt)'],
                                        category_orders={'category':['1', '2', '3', '4', '5']},
                                        color_discrete_map={str(c): px.colors.qualitative.Plotly[i % len(px.colors.qualitative.Plotly)] 
                                                            for i, c in enumerate(sorted(filtered_df['category'].dropna().unique()))}, 
                                       )
            
            map_fig.update_layout(mapbox_style="open-street-map", margin={"r": 0, "t": 30, "l": 0, "b": 0})
        else:
            print("Error: Length mismatch between filtered_df and coordinate lists.")
            map_fig.update_layout(mapbox_style="open-street-map",
                                  mapbox=dict(center=dict(lat=30, lon=-85), zoom=3),
                                  margin={"r": 0, "t": 30, "l": 0, "b": 0}, 
                                  title='Geographic Distribution of Hurricanes')
    else:
        map_fig.update_layout(mapbox_style="open-street-map", 
                              mapbox=dict(center=dict(lat=30, lon=-85), zoom=3), 
                              margin={"r": 0, "t": 30, "l": 0, "b": 0})

    # Hurricane Details
    if not filtered_df.empty:
        total_hurricanes = len(filtered_df)
        avg_wind = filtered_df['max-wind-(kt)'].mean()
        avg_pressure = filtered_df['central-pressure-(mb)'].mean()
        category_counts = filtered_df['category'].value_counts().to_dict()

        details = [
            dbc.Row([
                dbc.Col([html.Div([html.H5("Total of Hurricanes"), html.P(f"{total_hurricanes}", className="fs-2 fw-bold text-primary")],
                                  className="border rounded p-3 text-center")]),
                dbc.Col([html.Div([html.H5("Average Wind Speed"), html.P(f"{avg_wind:.1f} kt", className="fs-2 fw-bold text-primary")],
                                  className="border rounded p-3 text-center")]),
                dbc.Col([html.Div([html.H5("Average Central Pressure"), 
                                   html.P(f"{avg_pressure:.1f} mb",
                                          className="fs-2 fw-bold text-primary")],
                                  className="border rounded p-3 text-center")]),
                dbc.Col([html.Div([html.H5("Category Distribution"),
                                   html.P([html.Span(f"Cat {cat}: {count}",
                                                     className=f"badge {'bg-warning' if cat == '3' else 'bg-danger' if cat == '4' else 'bg-dark'} me-2") for cat, count in category_counts.items()])], className="border rounded p-3 text-center")])
            ])
        ]
    else:
        details = [html.P("No hurricanes match the selected filters", className="text-center fs-4 text-muted")]

    # Gráfico de predicción
    prediction_fig = go.Figure()
    if not filtered_df.empty:
        month_counts = filtered_df['month'].value_counts()
        ordered_months = ['Jan', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
        month_data = [month_counts.get(m, 0) for m in ordered_months]

        prediction_fig.add_trace(go.Bar(x=ordered_months, y=month_data, marker_color='royalblue'))
        prediction_fig.add_trace(go.Scatter(x=ordered_months, y=month_data, mode='lines', line=dict(color='firebrick', dash='dot'), name='Trend'))
        prediction_fig.update_layout(title='Historical Hurricane Frequency by Month', xaxis_title='Month', yaxis_title='NĂşmber of Hurricanes', showlegend=False)
    else:
        prediction_fig.add_annotation(x=0.5, y=0.5, text="No data available for prediction based on current filters", showarrow=False, font=dict(size=16))

    return polar_fig, map_fig, details, prediction_fig

if __name__ == '__main__':
    app.run_server(debug=True)

Any suggestions or comments are highly appreciated.

3 Likes

Hi @feanor_92
I’m also unable to run the notebook on kaggle. I’m getting this error when running the first cell:

ERROR: Could not find a version that satisfies the requirement dash_bootstrap_components (from versions: none)

Hi Avacsiglo21, love the explanation with the radial chart. I needed it.. Clean design. I am thinking of a way to add the context you give in the second part of the screen to the first part, maybe hover data map.
It took me some time to realize that after selecting a state (and maybe a timeframe) zooming is the bad alternative, scrolling down is the good one.

1 Like

@marieanne it might be good to report this to the Py.Cafe team as a bug. It’s weird that it’s working locally but not on their site.

Will do, I was a bit in doubt and busy. But I checked all my projects over there and the one posted on monday, also on LinkedIn as an IBCS experiment, working, didn’t work either. But in that case it was sufficient to replace app.run_server by app.run.

Nice app, @Ester .
When I load the app on py .cafe I get the graph on 1/3 of the page. I think it would be easier to read if it was displayed across the whole page. I would also make the color of the labels of the slider a bit darker so it’s easier to read.

Heatmap looks great. Only thing I would change is the color bar title from category to count, because when I saw category, I thought it was referring to the Hurricane Category (strength).

Thank you @adamschroeder!
Oh really, I wrote that down, I’ll fix it.
In pycafe, if I just reload the bar chart graph, it’ll be fine, I don’t know why yet.

Ohh Thanks Marianne, excellent point

@Avacsiglo21 your app has so much good information. A couple of questions and comments.

This button is not working for me. My bad, it’s working, I just have to scroll down to see the Div that opened. If I click on the button while I’m at the top of the page, I can’t see the text. It might be better to have it open as a modal.

What is the dashed line on the bar chart telling us? Is that giving us new information that the bars don’t give us?

In the map, because all the lat lon values are the same, it’s not easy to see all the hurricane’s per state. In this case it might be better to plot them on a bar chart with the count of hurricanes and category per state. Or we could do a choropleth map that plots the average category strength of all hurricanes that hit each state.