Figure Friday 2025 - week 43

join the Figure Friday session on October 31, at noon Eastern Time, to showcase your creation and receive feedback from the community. Figure Friday session cancelled this week.

What time of day sees the highest number of trick-or-treaters?

Answer this question and a few others by using Plotly on the Halloween dataset.

Things to consider:

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

Sample figure:
:pray: Thank you to @Mike_Purtell for the code and sample figure

Code for sample figure:
import pandas as pd
import plotly.express as px
 
# dataset: https://github.com/plotly/Figure-Friday/blob/main/2025/week-43/HalloweenTableau2024.csv
df = pd.read_csv('https://raw.githubusercontent.com/plotly/Figure-Friday/refs/heads/main/2025/week-43/HalloweenTableau2024.csv')
df = df.assign(Year = df['Date'].str.slice(-4))
 
# print(df)
fig=px.line(
    df,
    x='Year', y='Count',
    color='Time',
    title='Haloween Visitor trick or treater counts by time and year'
)
fig.show()

Below is a screenshot of the app that Plotly Studio created on top of this dataset:

Prompt for the bar chart:

Average attendance by day of week as a bar chart with dropdown to filter by specific years and toggle to show median vs mean values.

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 Data Plus Science for the data.

2 Likes

Hi FFF Community,

For Week 43, Halloween Week, I built a narrative-driven dashboard that transforms Halloween visitor counts into actionable patterns.

By the way, back in Week 31, we worked with a dataset of Halloween Candies data by FiveThirtyEight (at that time, I created a gamified dashboard, ‘Candy Emperor’).

This current data, on the other hand, comes from someone who diligently counted ‘trick-or-treaters’ in 30-minute intervals from 6:00 to 8:15 PM for 17 years. What started as simple tallies became a small dataset of 102 samples —a treasure trove of patterns waiting to be discovered.

This time, I engineered/created six features to capture the “Personality” or “Essence” of each Halloween Night:

  • Momentum: Did the night end stronger than it began? (The Grand Finale factor)
  • Sharpness: How explosive was the peak moment versus the lull?
  • Resilience: The ability to sustain activity through the measurement window.
  • Consistency: How predictable was the flow? (The Clockwork metric)
  • Trend: Was the night ascending or descending overall? (Linear slope)
  • Concentration: How clustered was the activity?"

From these metrics emerged 4 distinct Halloween archetypes:

  • Rocket (1 year): Builds momentum until the very end
  • Spike (5 years): Explosive peak, quick fade
  • Volatile (7 years): Unpredictable chaos—the Halloween norm
  • Retreat (4 years): Strong start, steady decline

Three Ways to Explore

  • Radar Chart: Compare the 6-metric DNA profile of any year vs. historical average
  • Timeline: See the flow with an IQR band showing historical variability
  • Chaos Map: Scatter plot of Momentum vs. Resilience—patterns cluster beautifully

Technical Highlights

  • Pure NumPy slope calculation (no sklearn for 6 data points—keeping it lean)
  • Feature normalization with domain-specific thresholds
  • Dynamic narrative generation that adapts to pattern type

The Insight That Surprised Me

2009 was the only “Rocket”—but not for the reason you’d think.

  • Total attendance: -8% below historical average
  • But the slope from 6:00→7:00: +244% growth
  • Pattern: Late bloomer that found its rhythm JUST in time

This revealed something profound: Pattern ≠ Volume. The shape of the curve tells a different story than total count. Some years are quiet but perfectly timed; others are chaotic yet successful.

:
The dashboard is live:

As usual questions more than welcome,this dashboard is not perfect a lot of thing to improve



4 Likes

Here goes the code

The code

import dash
from dash import dcc, html, Input, Output
import dash_bootstrap_components as dbc
import plotly.graph_objects as go
import pandas as pd
import numpy as np

============================================

GLOBAL CONSTANTS AND METADATA (English UI Text)

============================================

MOMENTUM_MIN = -1.5
MOMENTUM_MAX = 2.5
SHARPNESS_MAX = 1.2
SLOPE_MAX = 0.5
RADAR_CATEGORIES = [‘Momentum’, ‘Sharpness’, ‘Resilience’, ‘Consistency’, ‘Trend’, ‘Concentration’]

Narrative descriptions for the Radar Tooltip

RADAR_DESCRIPTIONS = {
‘Momentum’: “The night’s pace. Indicates if the flow of candy ended stronger than it began (Was there a Grand Finale?).”,
‘Sharpness’: “The Peak Roar. Measures how explosive the night’s peak moment was compared to the lull.”,
‘Resilience’: “Night’s Resilience. Ability to sustain activity within the 6:00pm to 8:15pm window (Warning: does not measure the full night).”,
‘Consistency’: ‘The Clockwork. Predictability of the flow. A high value means activity was steady, with no unexpected scares.’,
‘Trend’: ‘The Night’s Trend. Measures the general slope of the flow: did the night feel consistently ascending or descending?’,
‘Concentration’: ‘The Hour Bond. How clustered the activity is. Low dispersion = activity concentrated in a few key hours.’
}

Sidebar style

SIDEBAR_STYLE = {
“width”: “400px”,
“padding”: “2rem 1rem”,
“background”: “linear-gradient(180deg, #1a1a2e 0%, #16213e 100%)”,
“borderRight”: “3px solid #ff6b35”,
“height”: “100vh”,
“overflow”: “hidden”,
}

Main content style

CONTENT_STYLE = {
“padding”: “1.5rem”,
“background”: “#0f0f1e”,
“minHeight”: “100vh”,
“flexGrow”: 1,
“overflowY”: “auto”
}

DAY_MAPPING = {
‘Monday’: ‘Monday :crescent_moon:’, ‘Tuesday’: ‘Tuesday :fire:’, ‘Wednesday’: ‘Wednesday :mage:’,
‘Thursday’: ‘Thursday :ghost:’, ‘Friday’: ‘Friday :tada:’, ‘Saturday’: ‘Saturday :partying_face:’,
‘Sunday’: ‘Sunday :sleeping_face:
}

PATTERN_COLORS = {
:rocket: Rocket”: ‘#ff6b35’,
:high_voltage: Spike”: ‘#00d9ff’,
:roller_coaster: Volatile”: ‘#ff00ff’,
:sleeping_face: Retreat”: ‘#808080
}

Base Layout definition for plots

BASE_LAYOUT_CONFIG = {
‘paper_bgcolor’: ‘#16213e’,
‘plot_bgcolor’: ‘#16213e’,
‘font’: {‘color’: ‘#adb5bd’, ‘size’: 12},
‘showlegend’: True,
‘legend’: {
‘orientation’: “h”,
‘yanchor’: “top”,
‘y’: -0.2,
‘xanchor’: “center”,
‘x’: 0.5,
‘font’: {‘size’: 13}
}
}

============================================

AUXILIARY ANALYSIS FUNCTIONS

============================================

def safe_float(val, default=0.0):
“”“Converts to a float and ensures finiteness, otherwise returns the default.”“”
if isinstance(val, (np.ndarray, pd.Series, list)) and len(val) > 0:
val = val[0]
try:
f_val = float(val)
return f_val if np.isfinite(f_val) else default
except (TypeError, ValueError):
return default

def calculate_features(counts, year_data):
“”“Calculates the 6 key pattern metrics and normalizes them.”“”

counts_sum = counts.sum()
counts_mean = counts.mean()
peak_value = counts.max()

if len(counts) < 2 or counts_sum == 0 or counts_mean == 0 or peak_value == 0:
    return None 
    
# 1. MOMENTUM SCORE
early_avg = counts[:2].mean() if len(counts) >= 2 else 0
late_avg = counts[-2:].mean() if len(counts) >= 2 else 0
momentum = (late_avg - early_avg) / early_avg if early_avg > 0 else 0
momentum_scaled = (momentum - MOMENTUM_MIN) / (MOMENTUM_MAX - MOMENTUM_MIN)
momentum_normalized = np.clip(momentum_scaled, 0, 1) 

# 2. PEAK SHARPNESS
sharpness = (peak_value - counts_mean) / counts_mean
sharpness_normalized = np.clip(sharpness / SHARPNESS_MAX, 0, 1) 

# 3. RESILIENCE 
final_count = counts[-1]
resilience_normalized = np.clip(final_count / peak_value, 0, 1)

# 4. CONSISTENCY 
pct_changes = np.diff(counts) / counts[:-1]
pct_changes = pct_changes[~np.isinf(pct_changes) & ~np.isnan(pct_changes) & (counts[:-1] != 0)]
volatility = np.std(pct_changes) if len(pct_changes) > 0 else 0
consistency = 1 / (1 + volatility * 2) 

# 5. TREND (SLOPE) - PURE NUMPY IMPLEMENTATION
x_indices = np.arange(len(counts))

if np.std(counts) == 0:
    slope = 0.0
else:
    # Calculate slope (m) using Cov(x,y) / Var(x)
    slope, intercept = np.polyfit(x_indices, counts, 1)

trend_normalized = np.clip(abs(slope) / SLOPE_MAX, 0, 1)

# 6. CONCENTRATION 
cv = np.std(counts) / counts_mean
concentration = 1 / (1 + cv) 

features = {
    'Year': year_data['Year'].iloc[0],
    'Day_of_Week': year_data['Day of Week'].iloc[0],
    'Total_Count': counts_sum,
    'Peak_Time': year_data.loc[year_data['Count'].idxmax(), 'Time'],
    'Momentum': safe_float(momentum_normalized),
    'Sharpness': safe_float(sharpness_normalized),
    'Resilience': safe_float(resilience_normalized), 
    'Consistency': safe_float(consistency),
    'Trend': safe_float(trend_normalized),           
    'Slope': safe_float(slope),                      
    'Concentration': safe_float(concentration),
    'Counts': counts
}

# Pattern Classification (Uses the new Trend/Slope value)
if features['Slope'] > 0.1 and features['Momentum'] > 0.7:
    pattern = "🚀 Rocket" 
elif features['Slope'] < -0.1:
    pattern = "😴 Retreat" 
elif features['Sharpness'] > 0.6 and features['Resilience'] < 0.3:
    pattern = "⚡ Spike" 
else:
    pattern = "🎢 Volatile" 
    
features['Pattern'] = pattern
return features

def create_info_card(title, id_name, text_class, gradient_style):
“”“Generates a reusable information card component (English titles).”“”
narrative_titles = {
‘total-count’: ‘Souls Counted’,
‘peak-time’: ‘The Ritual Moment’,
‘day-of-week’: ‘The Harvest Day’,
‘vs-avg’: ‘vs. History’,
}
width_col = 3 if id_name not in [‘vs-avg’] else 3
return dbc.Col([
dbc.Card([
dbc.CardBody([
html.Div([
html.H6(narrative_titles.get(id_name, title), className=“text-muted mb-1”),
html.H5(id=id_name, className=f"{text_class} fw-bold mb-0")
], className=“text-center”)
])
], style={‘background’: gradient_style, ‘borderRadius’: ‘0.5rem’})
], width={‘size’: width_col, ‘md’: width_col, ‘sm’: 6})

def generate_narrative(data):
“”“Generates the narrative interpretation (English).”“”
pattern = data[‘Pattern’]

# 1. Pattern Summary (MAIN INSIGHT)
if pattern == "🚀 Rocket":
    desc = "INSIGHT: The 'Rocket' took off with a flow that not only grew but sustained itself, culminating in a powerful finish. Requires candy planning!"
elif pattern == "⚡ Spike":
    desc = "INSIGHT: The 'Spike' was a seismic event: a moment of brutal intensity that faded quickly. Calm arrived dramatically early."
elif pattern == "😴 Retreat":
    desc = "INSIGHT: 'Retreat'. The night had a general downward trend. The peak occurred very early, and activity decreased until the end."
else: # Volatile
    desc = "INSIGHT: 'Volatile' like a bubbling potion. The night was full of false peaks and unexpected drops. Impossible to predict!"

# 2. Key interpreted features
momentum_text = "Epic Final Strength. The end was stronger than the beginning." if data['Momentum'] > 0.6 else "Steady Pace. Similar start and end." if data['Momentum'] > 0.4 else "Slow Start. Took effort to get going."
resilience_text = "Mythical Resilience (up to 8:15pm). Final activity was almost equal to the peak." if data['Resilience'] > 0.7 else "Decent Resilience. Halved, but sustained." if data['Resilience'] > 0.4 else "Drastic Collapse. The night died quickly."
consistency_text = "Predictable. Stable flow without surprises." if data['Consistency'] > 0.7 else "Normal. Expected variations." if data['Consistency'] < 0.5 else "Chaotic. Totally unpredictable."
trend_text = "Strong Upward Trend. Linear regression was positive." if data['Slope'] > 0.1 else "Strong Downward Trend. Linear regression was negative." if data['Slope'] < -0.1 else "Flat Trend. The flow remained relatively level."

# 3. Open Question (Planning Narrative)
if pattern == "⚡ Spike":
    q = "Was there an event or weather that caused the terror not to last (low Resilience)? Next time, when should we close the doors?"
elif pattern == "💪 Endurance":
    q = "What factors (day of the week, weather) made the flow so constant and how can we replicate that marathon activity?"
elif pattern == "🚀 Rocket":
    q = "What spell was used to ensure this explosive and sustained growth? Is it a repeatable pattern or a one-time event?"
elif pattern == "😴 Retreat":
    q = "The trend is downwards. How can we delay the peak and use early advertising to increase arrival momentum?"
else:
    q = "How can we minimize volatility and find the 'sweet spot' for restocking?"
    
narrative_elements = [
    html.H5(f"💀 The Legacy of Year {int(data['Year'])}", className="mb-2 fw-bold text-warning"),
    html.H6(f"Identified Pattern: {pattern}", className="mb-2 fw-bold text-info"),
    html.P(desc, className="small text-light mb-3"),
    html.Div("The DNA of Fear (Historical Six Metrics):", className="fw-bold text-warning mb-1"),
    html.P([html.B("Pace: "), momentum_text], className="mb-1 small text-light"),
    html.P([html.B("Resilience: "), resilience_text], className="mb-1 small text-light"),
    html.P([html.B("Trend: "), trend_text], className="mb-1 small text-light"), 
    html.P([html.B("Reliability: "), consistency_text], className="mb-1 small text-light"),
    html.Div("🤔 The Next Prediction:", className="fw-bold text-danger mt-3 mb-1"),
    html.P(q, className="small text-light")
]
return dbc.Alert(narrative_elements, color="secondary", className="border border-warning")

============================================

LOAD AND PREPARE DATA

============================================

try:
# Ensure ‘HalloweenTableau2024.csv’ is available in the environment
halloween_data = pd.read_csv(‘HalloweenTableau2024.csv’)

# Preprocessing
halloween_data['Date'] = pd.to_datetime(halloween_data['Date'], format='%m/%d/%y')
halloween_data['Year'] = halloween_data['Date'].dt.year
halloween_data = halloween_data.sort_values(['Date', 'Time']).reset_index(drop=True)

# Feature Engineering
year_features = []
for year in halloween_data['Year'].unique():
    year_data = halloween_data[halloween_data['Year'] == year].sort_values('Time')
    features = calculate_features(year_data['Count'].values, year_data)
    if features:
        year_features.append(features)

features_df = pd.DataFrame(year_features)

# Calculate historical averages and percentiles for the new IQR band feature
counts_list = [pd.Series(f['Counts']) for f in year_features]
aligned_counts = pd.concat(counts_list, axis=1).fillna(0) if counts_list else pd.DataFrame() 

avg_counts = aligned_counts.mean(axis=1).values
# Calculate 25th and 75th percentiles for IQR band
q25_counts = aligned_counts.quantile(0.25, axis=1).values
q75_counts = aligned_counts.quantile(0.75, axis=1).values

except Exception as e:
# Print error in English for debugging
print(f"FATAL ERROR: Could not load ‘HalloweenTableau2024.csv’. Details: {e}")
features_df = pd.DataFrame()
avg_counts =
q25_counts =
q75_counts =

============================================

DASHBOARD LAYOUT

============================================

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

app.title = “:jack_o_lantern: Halloween Energy Dashboard”

Definition of information cards (English Titles)

card_definitions = [
(“Total Attendance”, ‘total-count’, “text-warning”, ‘linear-gradient(135deg, #4e3a1b 0%, #1a1a2e 100%)’),
(“Peak Time”, ‘peak-time’, “text-danger”, ‘linear-gradient(135deg, #4e1b1b 0%, #1a1a2e 100%)’),
(“Day of Week”, ‘day-of-week’, “text-info”, ‘linear-gradient(135deg, #1b3a4e 0%, #1a1a2e 100%)’),
(“vs History”, ‘vs-avg’, “text-light”, ‘linear-gradient(135deg, #2d1b4e 0%, #1a1a2e 100%)’),
]

app.layout = html.Div(style={‘display’: ‘flex’, ‘height’: ‘100vh’, ‘width’: ‘100vw’, ‘overflow’: ‘hidden’}, children=[

# SIDEBAR
html.Div([
    html.Div([
        html.Div("🎃", style={'fontSize': '4rem', 'textAlign': 'center'}),
        html.H2("The Candy Oracle", className="text-warning text-center mb-1", 
               style={'fontWeight': 'bold'}),
        html.P("Mystical Energy Pattern Analysis", className="text-muted text-center mb-4"),
        
        html.Hr(style={'borderColor': '#ff6b35', 'opacity': '0.3'}),
        
        # Selectors
        *[html.Div([
            html.Label(label, className="text-warning mb-2 fw-bold"),
            dcc.Dropdown(
                id=id_val,
                options=[{'label': opt_label, 'value': opt_value} 
                         for opt_label, opt_value in options] if not features_df.empty else [],
                value=default_value,
                clearable=False,
                style={'marginBottom': '1.5rem', 'color': '#0f0f1e'} 
            )
        ]) for label, id_val, options, default_value in [
            ("📅 The Ritual Year", 'year-selector', 
             [(f'{int(year)}', int(year)) for year in features_df['Year'].sort_values()], 
             int(features_df['Year'].max()) if not features_df.empty else None),
            ("⚖️ The Magic Contrast", 'compare-year', 
             [('The Historical Average', 'avg')] + [(f'{int(year)}', int(year)) 
                                                for year in features_df['Year'].sort_values()], 
             'avg')
        ]],
        
        html.Hr(style={'borderColor': '#ff6b35', 'opacity': '0.3'}),
        
        # Radio Items (English View Selector)
        html.Label("🔮 Which Form of Fear Will You See?", className="text-warning mb-2 fw-bold"),
        dbc.RadioItems(
            id='view-selector',
            options=[
                {'label': '🧬 DNA Profile (Metrics)', 'value': 'radar'},
                {'label': '📈 The Timeline (Hourly Flow)', 'value': 'timeline'},
                {'label': '🎯 Chaos Map (Momentum vs. Resilience)', 'value': 'scatter'}, # NEW VIEW
            ],
            value='radar',
            className="text-light",
            labelStyle={'marginBottom': '0.8rem'}
        ),
        
        html.Hr(style={'borderColor': '#ff6b35', 'opacity': '0.3'}),
        
        html.Div(id='year-info', className="mt-3")
        
    ], style={'maxHeight': '100%', 'overflowY': 'auto', 'paddingRight': '10px'}) 
    
], style=SIDEBAR_STYLE),

# MAIN CONTENT
html.Div([
    html.Div([
        html.H4("🕯️ Unveiling the Night's Secret", className="text-info fw-bold mb-1"),
        html.P("This digital Grimoire analyzes the 'trick-or-treat' energy by 30-minute intervals. Our objective: predict the exact moment to restock the candy arsenal and understand the Legacy of each Halloween.",
               className="text-muted small mb-4 border-bottom border-secondary pb-3")
        , # Cards generated programmatically
        dbc.Row([create_info_card(*card) for card in card_definitions], className="mb-3 g-3"),
        
        # Main Graph
        dbc.Row([
            dbc.Col([
                dbc.Card([
                    dbc.CardBody([
                        dbc.Spinner(
                            dcc.Graph(id='main-graph', style={'height': '70vh'}),
                            color="warning"
                        )
                    ])
                ], style={'background': 'linear-gradient(180deg, #1a1a2e 0%, #16213e 100%)', 'borderRadius': '0.5rem'})
            ], width=12)
        ])
        
    ], className="flex-grow-1"), 

    # FOOTER
    html.Footer([
        html.P([
            "Dashboard powered by the ",
            html.Span("Mystery of Plotly | Dash", className="text-warning fw-bold"),
            " | by Avacsiglo21 | Data courtesy of ",
            html.A("Data Plus Science", href="https://www.dataplusscience.com/", target="_blank", className="text-info fw-bold"),
            "."
        ], className="text-center text-muted small mb-0")
    ], style={
        'padding': '1rem 0', 
        'borderTop': '1px solid #2d3b55', 
        'marginTop': '1rem',
        'flexShrink': 0 
    })
    
], style=CONTENT_STYLE)

])

============================================

CALLBACK (CENTRAL PLOTTING LOGIC)

============================================

@app.callback(
[Output(‘main-graph’, ‘figure’),
Output(‘total-count’, ‘children’),
Output(‘peak-time’, ‘children’),
Output(‘day-of-week’, ‘children’),
Output(‘vs-avg’, ‘children’),
Output(‘year-info’, ‘children’)],
[Input(‘year-selector’, ‘value’),
Input(‘compare-year’, ‘value’),
Input(‘view-selector’, ‘value’)]
)
def update_dashboard(selected_year, compare_year, view):
if features_df.empty or selected_year is None:
return go.Figure(), “N/A”, “N/A”, “N/A”, “N/A”, dbc.Alert(
“ERROR: The Data Grimoire could not be loaded. Check for the presence and validity of ‘HalloweenTableau2024.csv’.”,
color=“danger”)

year_data = features_df[features_df['Year'] == selected_year].iloc[0]

# --- Shared Metadata Calculation ---
avg_total = features_df['Total_Count'].mean()
diff_pct = ((year_data['Total_Count'] - avg_total) / avg_total) * 100

if diff_pct > 0:
    vs_avg_text, vs_avg_color = f"↑ +{diff_pct:.0f}% Better", "success"
elif diff_pct < 0:
    vs_avg_text, vs_avg_color = f"↓ {diff_pct:.0f}% Less", "danger"
else:
    vs_avg_text, vs_avg_color = "— Equal", "info"
    
year_info = generate_narrative(year_data)
display_day = DAY_MAPPING.get(year_data['Day_of_Week'], year_data['Day_of_Week'])

fig = go.Figure()
layout = BASE_LAYOUT_CONFIG.copy() 

# --- GRAPH GENERATION ---

if view == 'radar':
    # Generate hovertext that includes the category description (English).
    hover_descriptions = [RADAR_DESCRIPTIONS[cat] for cat in RADAR_CATEGORIES]
    
    # Radar Trace (Selected Year)
    values = [float(year_data[cat]) for cat in ['Momentum', 'Sharpness', 'Resilience', 'Consistency', 'Trend', 'Concentration']]

    fig.add_trace(go.Scatterpolar(
        r=values, 
        theta=RADAR_CATEGORIES, 
        fill='toself', 
        name=f'{selected_year} (Pattern: {year_data["Pattern"]})',
        line=dict(color='#ff6b35', width=3), 
        fillcolor='rgba(255, 107, 53, 0.4)',
        
        # ADVANCED TOOLTIP IMPLEMENTATION:
        customdata=np.array([hover_descriptions, values]).T, 
        hovertemplate=(
            "<b>Metric:</b> %{theta}<br>"
            "<b>Value (%{selected_year}):</b> %{r:.2f}<br>"
            "---<br>"
            "<b>Definition:</b> %{customdata[0]}" 
            "<extra></extra>" 
        )
    ))
    
    # Comparison Logic
    comp_name_label = ""
    if compare_year == 'avg':
        comp_r = [float(features_df[cat].mean()) for cat in ['Momentum', 'Sharpness', 'Resilience', 'Consistency', 'Trend', 'Concentration']]
        comp_name, comp_line = 'The Grand Historical Average', dict(color='#00d9ff', width=2, dash='dash')
        comp_name_label = 'The Grand Historical Average'
    else:
        compare_data = features_df[features_df['Year'] == compare_year].iloc[0]
        comp_r = [float(compare_data[cat]) for cat in ['Momentum', 'Sharpness', 'Resilience', 'Consistency', 'Trend', 'Concentration']]
        comp_name, comp_line = f'{compare_year} (Pattern: {compare_data["Pattern"]})', dict(color='#bb86fc', width=2, dash='dot')
        comp_name_label = f'{compare_year}'

    # Radar Trace (Comparison Year/Average)
    fig.add_trace(go.Scatterpolar(
        r=comp_r, 
        theta=RADAR_CATEGORIES, 
        name=comp_name, 
        line=comp_line, 
        fillcolor='rgba(0, 217, 255, 0.1)',
        customdata=np.array([hover_descriptions, comp_r]).T,
        hovertemplate=(
            "<b>Metric:</b> %{theta}<br>"
            f"<b>Value ({comp_name_label}):</b> %{{r:.2f}}<br>"
            "---<br>"
            "<b>Definition:</b> %{customdata[0]}"
            "<extra></extra>"
        )
    ))
    
    # Specific Radar Layout Configuration
    layout.update(polar=dict(
        radialaxis=dict(visible=True, range=[0, 1.05], gridcolor='#2d3b55', color='#adb5bd', tickfont=dict(size=10)),
        angularaxis=dict(gridcolor='#2d3b55', color='#ff6b35'),
        bgcolor='#16213e'
    ), title={'text': f"🧬 The Sweet Death Profile: {selected_year} vs. {comp_name_label}", 'font': {'size': 20, 'color': '#ff6b35'}, 'x': 0.5, 'xanchor': 'center'})
    
elif view == 'timeline':
    times = halloween_data['Time'].unique().tolist()
    
    comp_y = None
    comp_name_label = ""
    
    # Historical IQR Band Visualization (when comparing to average)
    if compare_year == 'avg' and len(avg_counts) == len(times) and len(q25_counts) == len(times):
        
        comp_name_label = 'The Grand Historical Average'
        
        # 1. Trace for 75th Percentile (Upper Boundary of the IQR Band)
        fig.add_trace(go.Scatter(
            x=times, y=q75_counts, mode='lines', 
            line=dict(width=0), 
            showlegend=False, 
            hoverinfo='none',
            name='75th Percentile'
        ))
        
        # 2. Trace for Average (The center line, which fills the space to the next trace)
        fig.add_trace(go.Scatter(
            x=times, y=avg_counts, mode='lines',
            name='The Grand Historical Average (Mean)',
            line=dict(color='#00d9ff', width=3, dash='dash'),
            fill='tonexty', 
            fillcolor='rgba(0, 217, 255, 0.1)', 
            hoverlabel={'namelength': -1}
        ))

        # 3. Trace for 25th Percentile (Lower Boundary of the IQR Band)
        fig.add_trace(go.Scatter(
            x=times, y=q25_counts, mode='lines', 
            line=dict(width=0, color='#00d9ff'), 
            showlegend=False, 
            hoverinfo='none',
            name='25th Percentile'
        ))

    elif compare_year != 'avg':
        compare_data = features_df[features_df['Year'] == compare_year].iloc[0]
        comp_y, comp_name, comp_line = compare_data['Counts'], f'{compare_year} ({compare_data["Pattern"]})', dict(color='#bb86fc', width=3, dash='dot')
        comp_name_label = f'{compare_year}'
        
        # Add the selected comparison year trace
        fig.add_trace(go.Scatter(
            x=times, y=comp_y, mode='lines+markers', name=comp_name, 
            line=comp_line, marker=dict(size=10)
        ))
        
    # Trace for the currently selected year (always on top)
    fig.add_trace(go.Scatter(
        x=times, y=year_data['Counts'], mode='lines+markers',
        name=f'{selected_year} (Pattern: {year_data["Pattern"]})',
        line=dict(color='#ff6b35', width=4),
        marker=dict(size=12, line=dict(color='#16213e', width=2))
    ))
        
    # Specific Timeline Layout Configuration 
    layout.update(
        xaxis={'title': 'The Night Clock (Time)', 'gridcolor': '#2d3b55', 'color': '#ff6b35'},
        yaxis={'title': 'The Tide of Visitors (Count)', 'gridcolor': '#2d3b55', 'color': '#ff6b35'},
        title={'text': f"📈 The Tide of Fear: Flow of {selected_year} vs. {comp_name_label}", 'font': {'size': 20, 'color': '#ff6b35'}, 'x': 0.5, 'xanchor': 'center'}
    )

elif view == 'scatter':
    # SCATTER PLOT (CHAOS MAP) - Momentum vs. Resilience
    
    # # --- BACKGROUND TRACES (All Years) 

    for pattern_name, color in PATTERN_COLORS.items():
        pattern_data = features_df[features_df['Pattern'] == pattern_name]
        
        # Count BEFORE filtering (this is the true historical count)
        total_count = len(pattern_data)  # 🔧 LÍNEA NUEVA
        
        # Filter out the currently selected year from the background
        if not pattern_data.empty and selected_year in pattern_data['Year'].values:
            pattern_data = pattern_data[pattern_data['Year'] != selected_year]
            
        if total_count > 0:  # 🔧 CAMBIADO: de "not pattern_data.empty" a "total_count > 0"
            fig.add_trace(go.Scatter(
                x=pattern_data['Momentum'], y=pattern_data['Resilience'], mode='markers',
                marker=dict(size=10, opacity=0.6, color=color, line=dict(color='white', width=1)),
                name=f'{pattern_name} ({total_count})'  # 🔧 CAMBIADO: de "len(pattern_data)" a "total_count"
        ))

    # --- FOCUSED TRACE (Selected Year) ---
    selected_size = 15 # Base size for the highlighted dot
    
    # Determine the size based on how much the year deviates from the historical average in total count (Narrative only)
    total_count_avg = features_df['Total_Count'].mean()
    size_scale_factor = np.clip(np.abs(year_data['Total_Count'] - total_count_avg) / total_count_avg, 0, 1) * 10
    
    fig.add_trace(go.Scatter(
        x=[year_data['Momentum']], y=[year_data['Resilience']], mode='markers',
        marker=dict(size=selected_size + size_scale_factor, color=PATTERN_COLORS.get(year_data['Pattern'], 'white'), line=dict(color='white', width=4)),
        name=f'The Chosen One: {selected_year}', showlegend=False,
        hovertemplate=(
            f"<b>Year:</b> {selected_year}<br>"
            f"<b>Pattern:</b> {year_data['Pattern']}<br>"
            "<b>Momentum:</b> %{x:.2f}<br>"
            "<b>Resilience:</b> %{y:.2f}"
            "<extra></extra>"
        )
    ))
    
    # --- SCATTER PLOT LEGEND CORRECTION (Uses BASE_LAYOUT but confirms title) ---
    layout['legend'].update({
        'title': "Fear Energy Patterns",
        'y': -0.2, # Confirms position below the plot area
        'yanchor': 'top'
    })
    
    # Specific Scatter Layout Configuration
    layout.update(
        xaxis={'title': 'Pace → (Momentum/Strong Finish)', 'gridcolor': '#2d3b55', 'color': '#ff6b35', 'range': [-0.1, 1.1]},
        yaxis={'title': 'Stamina → (Resilience/Sustainability)', 'gridcolor': '#2d3b55', 'color': '#ff6b35', 'range': [-0.1, 1.1]},
        title={'text': f"🎯 The Map of Chaos: Historical Patterns vs. {selected_year}", 'font': {'size': 20, 'color': '#ff6b35'}, 'x': 0.5, 'xanchor': 'center'}
    )


fig.update_layout(layout)

# --- Outputs ---
return (fig, 
        f"{int(year_data['Total_Count']):,}", 
        year_data['Peak_Time'],
        display_day, 
        html.Span(vs_avg_text, className=f"text-{vs_avg_color} fw-bold"),
        year_info)

server = app.server

2 Likes

The Sweet Death Profile.

What an awesome title, @Avacsiglo21 .

Looks like the ‘consistency’ of trick-or-treating in 2021 was much below average.

I wonder if there was a storm that came in around 7:30. It’s like it started raining, which caused a big unexpected drop.

The summary on the left is also impressive. Is this AI generated?

3 Likes

Yes, AI helped write the Halloween narrative shown in the left summary.

1 Like

Hi @Avacsiglo21, great job on this week’s dashboard. Especially love the Halloween themed colors.

1 Like

Thanks a lot Mike

1 Like

For week 43, I wanted the convenience of Power Point for easily stepping from slide to slide, with the power of plotly for interacting with data using hover info, zoom, and other tools. So I made this dashboard using Dash Mantine carousel, with buttons on the left and right to move forward or backward by one slide. Please look through each slide and read the comments to follow the progression from a crowded and noisy slide to a clean one with call to action.

I look forward to using this approach in a professional situation. Will have to refine this a bit but I think it is doable.

I appreciate any comments, questions or suggestions. Happy Halloween to all of you.

Here is the app link to Plotly Cloud:

Here is a screenshot (Notice the arrows along the right and left side to advance or go back:

Here is the code:

import polars as pl
import polars.selectors as cs
import os
import plotly.express as px
import plotly.graph_objects as go
import dash
from dash import Dash, dcc, html, Input, Output
import dash_mantine_components as dmc
dash._dash_renderer._set_react_version('18.2.0')

#----- GLOBALS -----------------------------------------------------------------
style_horizontal_thick_line = {'border': 'none', 'height': '4px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 32}

style_h2 = {'text-align': 'center', 'font-size': '40px', 
            'fontFamily': 'Arial','font-weight': 'bold', 'color': 'gray'}

template_list = ['ggplot2', 'seaborn', 'simple_white', 'plotly','plotly_white',
    'plotly_dark', 'presentation', 'xgridoff', 'ygridoff', 'gridon', 'none']

dmc_text_red = {
    'fontSize':'16px', 
    'color':'red', 
    'textAlign':'left',
    'marginLeft':'100px'
}
# use dictionary dmc_text_red for and modify the color
dmc_text_gray = dmc_text_red.copy()
dmc_text_gray['color'] = 'gray'


{'fontSize':'16px', 'color':'gray', 'textAlign':'left','marginLeft':'100px'},
#----- LOAD AND CLEAN THE DATASET ----------------------------------------------
df = (
    pl.read_csv('Halloween.csv')
    .select(
        YEAR = pl.col('Date')
            .str.split('/')
            .list.get(2)
            # .cast(pl.Categorical),
            .cast(pl.UInt16()),
        DAY = pl.col('Day of Week').str.slice(0,3),
        TIME = pl.col('Time').str.replace('pm',' PM'),
        COUNT = pl.col('Count').cast(pl.UInt8()),
    )
)

df_yearly = (
        df
        .group_by(['YEAR'])
        .agg(pl.sum('COUNT').alias('TOTAL_COUNT'))
        .sort('YEAR')
    )
count_2022 = df_yearly.filter(pl.col('YEAR') == 2022).item(0, 'TOTAL_COUNT')
count_2024 = df_yearly.filter(pl.col('YEAR') == 2024).item(0, 'TOTAL_COUNT')
count_2025 = int(count_2024 + 0.5*(count_2024 - count_2022))

df_future = pl.DataFrame({  # predict 2025 by extrapolating from 2022, 2024
        'YEAR':           [2024, 2025],
        'TOTAL_COUNT' :   [count_2024, count_2025]
    })

time_list = sorted(df.unique('TIME')['TIME'].to_list())

time_color_dict = {}
for i, time in enumerate(time_list):
    # time_color_dict[time] = px.colors.qualitative.Light24[i]
    time_color_dict[time] = px.colors.qualitative.Alphabet[i]
    time_color_dict[time] = px.colors.qualitative.D3[i]
    
time_color_dict

#----- DASH COMPONENTS------ ---------------------------------------------------
select_template = (
    dmc.Select(
        label='Pick your favorite Plotly template',
        id='template',
        data= template_list,
        value=template_list[4],
        searchable=False,  # Enables search functionality
        clearable=True,    # Allows clearing the selection
        size='sm',
    ),
)

def get_fig(template):
    fig=px.line(
        df,
        x='YEAR',
        y='COUNT',
        color='TIME',
        color_discrete_map=time_color_dict,
        template = template,
        markers=True,
        title='Halloween TTT<br><sup>Slide 1</sup>',
        height=400, width=800
    )
    fig.update_layout(
        xaxis=dict(
            showline=True, 
            linewidth=1, 
            linecolor='gray', 
            mirror=True,
            showgrid=True,      # show vertical grid lines
            tickmode='linear',  # ensure ticks are evenly spaced
            dtick=1             # one tick/gridline per category
            ),
        yaxis=dict(
            showline=True, 
            linewidth=1, 
            linecolor='gray', 
            mirror=True,
            showgrid=True       # show horizontal grid lines
            ),
        title_y=0.97,
    )
    fig.update_traces(line=dict( width=1))

    # Customize gridlines
    fig.update_xaxes(
        showgrid=True,           # Ensure gridlines are visible
        gridwidth=1,             # Thickness of vertical gridlines
        gridcolor='gray',        # Color of vertical gridlines
    )

    fig.update_yaxes(
        showgrid=True,          # Ensure gridlines are visible
        gridwidth=1,            # Thickness of horizontal gridlines
        gridcolor='gray',        # Color of horizontal gridlines
    )
    fig.update_layout(margin=dict(l=0, r=0, t=50, b=0))

    return fig

def get_carousel_slide(reviewer_text, id):
    slide = dmc.CarouselSlide(
        dmc.Stack([
            dmc.Center(dcc.Graph(figure=go.Figure(), id=id)),
            dmc.Text('Comment:', style=dmc_text_red),
            dmc.Text(reviewer_text, style=dmc_text_gray)
        ])
    )
    return slide

def update_fig(fig, slide_number, template):
    updated_fig = go.Figure(fig)
    if slide_number == 2:   # change the plot title
        updated_fig.update_layout(
            title=dict(
                text=(
                    'Halloween Trick-or-Treaters by Time (TTT)<br>' + 
                    f'<sup>Slide {slide_number}</sup>'
                )
            )
        )
    if slide_number == 3: # change line shape to spline
        updated_fig.update_traces(line=dict(shape='spline'))
        updated_fig.update_layout(title=dict(
            text=(
                'Halloween Trick-or-Treaters by Time (TTT)<br>' +
                f'<sup>Slide {slide_number}</sup>'
            ))
        )
    
    if slide_number == 4: # remove x-axis and y-axis gridlines
        updated_fig.update_xaxes(showgrid=False, zeroline=False)
        updated_fig.update_yaxes(showgrid=False, zeroline=False)
        updated_fig.update_layout(title=dict(
            text=(
                'Halloween Trick-or-Treaters by Time (TTT)<br>' +           
                f'<sup>Slide {slide_number}</sup>'
            ))
        )

    if slide_number == 5: # remove top  and right borders
        updated_fig.update_layout(
            xaxis=dict(
                showline=True, showgrid=False, zeroline=False, mirror=False),
            yaxis=dict(
                showline=True, showgrid=False, zeroline=False, mirror=False),
            title=dict(
                text=(
                    'Halloween Trick-or-Treaters by Time (TTT)<br>'+ 
                    f'<sup>Slide {slide_number}</sup>'
                ))
        )

    if slide_number == 6:    # remove x-axis label
        updated_fig.update_xaxes(title='')
        updated_fig.update_layout(
            title=dict(
                text= ( 
                    'Halloween Trick-or-Treaters by Time (TTT)<br>' + 
                    f'<sup>Slide {slide_number}</sup>'
                ))
        )

    if slide_number == 7:     # remove legend
        updated_fig.update_layout(
            showlegend=False,
            title=dict(
                text=(
                    'Halloween Trick-or-Treaters by Time (TTT)<br>'
                    f'<sup>Slide {slide_number}</sup>'
                )
            )
        )

    if slide_number == 8:     # add color coded annotations
        count_list = df.filter(pl.col('YEAR') == 2024)['COUNT'].to_list()
        updated_fig.update_xaxes(range=[2008, 2025.75])
        y_shift_list = [15, 10, 10, 10, -15, -15]
        for i, shift in enumerate(y_shift_list):
            updated_fig.add_annotation(
                x=1,xref='paper', 
                y=count_list[i], yref='y', 
                text=time_list[i], showarrow=False, 
                font=dict(color=time_color_dict[time_list[i]], size=14), 
                yshift=y_shift_list[i])
        updated_fig.update_layout(
            showlegend=False,
            title=dict(
                text=(
                    'Halloween Trick-or-Treaters by Time (TTT)<br>' + 
                    f'<sup>Slide {slide_number}</sup>'
                )
            )
        )

    if slide_number == 9:     # add vertical line and annotation for covid
        updated_fig.add_vline(
            x=2020, 
            line_width=2, 
            line_dash='dash',
            line_color='gray',
            annotation_text='Covid-19 Pandemic',
        )
        updated_fig.update_layout(
            showlegend=False,
            title=dict(
                text=(
                    'Halloween Trick-or-Treaters by Time (TTT)<br>' +
                    f'<sup>Slide {slide_number}</sup>'
                ))
        )
    if slide_number == 10:     # add vertical line for 2013 (heavy rain)
        updated_fig.add_vline(
            x=2013, 
            line_width=2, 
            line_dash='dash',
            line_color='gray',
            annotation_text='1 inch of rain',
        )
        updated_fig.update_layout(
            showlegend=False,
            title=dict(
                text= (
                'Halloween Trick-or-Treaters by Time (TTT)<br>' + 
                f'<sup>Slide {slide_number}</sup>'
                ))
        )

    if slide_number == 11:     # aggregate all time points, single trace by year
        updated_fig=px.line(
            df_yearly,
            x='YEAR',
            y='TOTAL_COUNT',
            template = template,
            markers=True,
            title='Total Halloween Trick-or-Treaters by Year',
            height=400, width=800,
            line_shape='spline',
        )
        updated_fig.update_traces(line=dict( width=1))
        updated_fig.update_layout(margin=dict(l=0, r=0, t=50, b=0))
        updated_fig.add_vline(
            x=2020, 
            line_width=2, 
            line_dash='dash',
            line_color='gray',
            annotation_text='Covid-19 Pandemic',
        )
        updated_fig.add_vline(
            x=2013, 
            line_width=2, 
            line_dash='dash',
            line_color='gray',
            annotation_text='1 inch of rain',
        )
        for i, year in enumerate([2022, 2023, 2024]):
            year_count = (
                df_yearly
                .filter(pl.col('YEAR') == year)
                .item(0, 'TOTAL_COUNT')
            )
            updated_fig.add_annotation(
                x=year,xref='x', 
                y=year_count, yref='y', 
                text=f'{year_count}', showarrow=False, 
                font=dict(color='gray', size=14), 
                yshift=20
                )
        updated_fig.update_xaxes(title='')
        updated_fig.update_layout(
            xaxis=dict(
                showline=True, 
                linewidth=1, 
                linecolor='gray',
                mirror=False, 
                ),
            yaxis=dict(
                showline=True, 
                linewidth=1, 
                linecolor='gray', 
                mirror=False     
                ),
            title_y=0.97,
        )
        updated_fig.update_layout(
            showlegend=False,
            title=dict(
                text=(
                    'Halloween Trick-or-Treaters Aggregated by Year (TTT)<br>'
                    f'<sup>Slide {slide_number}</sup>'
                ))
        )
        updated_fig.update_xaxes(showgrid=False, zeroline=False)
        updated_fig.update_yaxes(showgrid=False, zeroline=False)

    if slide_number == 12:   # aggregate time points, single trace by year
        updated_fig.add_trace(go.Scatter(
            x=df_future['YEAR'],
            y=df_future['TOTAL_COUNT'],
            mode='lines+markers',
            line=dict(
                color='gray',
                dash='dot'
            ),
            marker=dict(size=8),
            name='Gray Dashed Line',
            showlegend=False
        ))
        updated_fig.add_annotation(
            x=2025,xref='x', 
            y=count_2025, yref='y', 
            text=f'{count_2025}', showarrow=False, 
            font=dict(color='red', size=14), 
            yshift=20
            )
        updated_fig.update_layout(
            showlegend=False,
            title=dict(
                text=(
                'Halloween Trick-or-Treaters Aggregated by Year<br>' +
                f'<sup>Slide {slide_number}</sup>'
                ))
        )

    if slide_number == 13:     # aggregate all time points, single trace by year
        df_ordered_days = (
            pl.DataFrame({
                'DAY'     : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
                'DAY_NUM' : [    1,     2,     3,     4,     5,     6,     7],
                'DAY_COLOR' : ['#d3d3d3']*5 + ['orange', '#d3d3d3'],
            })
        )
        dict_day_color = dict(zip(
            df_ordered_days['DAY'], 
            df_ordered_days['DAY_COLOR']
        ))

        df_avg_by_day = (
            df
            .with_columns(DAY_COUNT = (pl.col('DAY').len().over('DAY')/6))
            .with_columns(
                NORM_DAY_COUNT = (pl.col('COUNT')/(pl.col('DAY_COUNT'))))
            .with_columns(
                DAY_TOTAL = (pl.col('NORM_DAY_COUNT').sum().over('DAY')))
            .unique(['DAY', 'DAY_TOTAL'])
            .select(['DAY', 'DAY_TOTAL'])
            .join(
                df_ordered_days,
                on='DAY',
                how='left'
            )
            .sort('DAY_NUM')
        )
        updated_fig=px.histogram(
            df_avg_by_day,
            x='DAY',
            y='DAY_TOTAL',
            color='DAY',
            color_discrete_map=dict_day_color,
            template = template,
            title=(
                '<b>Call to Action:</b>' +
                'Halloween 2025 is on a Friday (tonight) -- time to shop<br>' + 
                f'<sup>Slide {slide_number}</sup>'
            ),
            height=400, width=800,
        )
        updated_fig.update_layout(
            xaxis=dict(
                showline=True, 
                linewidth=1, 
                linecolor='gray', 
                mirror=False,
                showgrid=False,      # show vertical grid lines
                tickmode='linear',  # ensure ticks are evenly spaced
                dtick=1             # one tick/gridline per category
                ),
            yaxis=dict(
                showline=True, 
                linewidth=1, 
                linecolor='gray', 
                mirror=False,
                showgrid=False       # show horizontal grid lines
                ),
            title_y=0.97,
        )
        updated_fig.update_xaxes(title='')
        updated_fig.update_yaxes(title='NORMALIZED TRICK OR TREATER COJNT PER DAY')
        updated_fig.update_layout(
            margin=dict(l=0, r=0, t=50, b=0),showlegend=False)
                    
    return updated_fig

# #----- DASH APPLICATION STRUCTURE---------------------------------------------
fig=go.Figure()
app = Dash()
server = app.server
app.layout =  dmc.MantineProvider([
    html.Hr(style=style_horizontal_thick_line),
    dmc.Text('Trick or Treating', ta='center', style=style_h2), 
    html.Hr(style=style_horizontal_thick_line), 
    dmc.Grid(children = [dmc.GridCol(select_template, span=3, offset = 1),]),  
    dmc.Space(h=50),
    dmc.Carousel(
        withIndicators=False,
        height=500,
        slideGap='md',
        controlsOffset='sm',
        controlSize=60,
        withControls=True,
        children = [   
            get_carousel_slide(
                'Graph has trick-or-treater counts by time of day and year, ' +
                'but what the heck is TTT?',
                'fig_01'
            ),
            get_carousel_slide(
                'These curves so pointy, can they be smoothed out?',
                'fig_02'
            ),
            get_carousel_slide(
                (
                "Get rid of the gridlines. This is not PowerPoint, " +
                ' use plotly hover tips to interact with the data and ' +
                'show the exact value of any point'
                ),
                'fig_03'
            ),
            get_carousel_slide(
                "Looking better. Top and right borders are not needed, " + 
                "so get rid of them too",
                'fig_04'
            ),
            get_carousel_slide(
                'Remove the x-axis label, "YEAR", it is obvious', 
                'fig_05'
            ),
            get_carousel_slide(
                "Legend is hard to follow with spaghetti-like traces,  " +
                "let's remove it",
                'fig_06'
            ),
            get_carousel_slide(
                "Now I am more confused, hard to tell what is what, " +
                    "can you put the legend back?",
                'fig_07'
            ),
            get_carousel_slide(
                'I could, but this approach with color coded ' +
                'annotations placed near the traces they represent is better. ' +
                'But what happened in 2020?',
                'fig_08'
            ),
            get_carousel_slide(
                'If 2020 was the start of the pandemic, then what happened in 2013?',
                'fig_09'
            ),
            get_carousel_slide(
                'Cincinatti Ohio had heavy rainfall in 2013, affecting turnout. ' + 
                "I don't care about the half-hourly data, " +
                "I want to know much candy to buy for this year.",
                'fig_10'
            ),
            get_carousel_slide(
                "Thanks for combining the data to show yearly totals, and " + 
                "labeling values from 2022 to 2024. Can we extrapolate to get a value for 2025?",
                'fig_11'
            ),
            get_carousel_slide(
                "Extrapolated value for 2025 is at the end of the dashed line, " +
                "which distinguishes future projections from past data." +
                'Halloween is on a Friday (tonight). Do more people show up ' +
                'on Fridays than on weeknights?',
                'fig_12'
            ),
            get_carousel_slide(
                "I thought Friday would be the busiest day for Halloween. This " +
                "data shows that Monday is busiest, Friday is the least busiest. " +
                'Glad I checked',
                'fig_13'
            )
    ])
])

@app.callback(
    Output('fig_01', 'figure'),
    Output('fig_02', 'figure'),
    Output('fig_03', 'figure'),
    Output('fig_04', 'figure'),
    Output('fig_05', 'figure'),
    Output('fig_06', 'figure'),
    Output('fig_07', 'figure'),
    Output('fig_08', 'figure'),
    Output('fig_09', 'figure'),
    Output('fig_10', 'figure'),
    Output('fig_11', 'figure'),
    Output('fig_12', 'figure'),
    Output('fig_13', 'figure'),
    Input('template', 'value'),
)
def callback(template):
    fig_01 = get_fig(template)
    fig_02 = update_fig(fig_01, 2, template)
    fig_03 = update_fig(fig_02, 3, template)
    fig_04 = update_fig(fig_03, 4, template)
    fig_05 = update_fig(fig_04, 5, template)
    fig_06 = update_fig(fig_05, 6, template)
    fig_07 = update_fig(fig_06, 7, template)
    fig_08 = update_fig(fig_07, 8, template)
    fig_09 = update_fig(fig_08, 9, template)
    fig_10 = update_fig(fig_09, 10, template)
    fig_11 = update_fig(fig_10, 11, template)
    fig_12 = update_fig(fig_11, 12, template)
    fig_13 = update_fig(fig_12, 13, template)
    
    return (
        fig_01, fig_02, fig_03, fig_04, fig_05, fig_06, fig_07, fig_08,
        fig_09, fig_10, fig_11, fig_12, fig_13
    )

if __name__ == '__main__':
    app.run(debug=True)```
3 Likes

Hi Mike,

Nice job! I have a quick question regarding some of the templates.

When I select the templates presentation, xgridoff, ygridoff, or gridon, the x and y scales (or axes labels/tick marks) seem to hide or disappear.

I’ve noticed that these templates also seem to be “bigger” or have a different size/layout compared to the others (like plotly_white, ggplot2, or seaborn).

Is there a way to fix this issue with the scales?

What a magnificent way to use the Dash Mantine carousel :flexed_biceps: @AnnMarieW you should take a look if you have a minute. This is a great way to do presentations. Thank you for the idea, @Mike_Purtell .

Also, those comments at the bottom of the page are great, because they create the whole story.
Similar to what @Avacsiglo21 has said, do you know why the graph title gets cut off sometimes? Does this have to do with the carousel?

1 Like

Thank you @adamschroeder. I don’t know why the graph gets cut off but I noticed I could avoid that by zooming out a little bit. I am intrigued by the idea of plotly interaction on a presentation slide and will pursue this further. Collaborative exploration and experimentation like this are great benefit of this group. Happy Halloween to my Freaky, Frightening, and Fun Figure Friday Friends.

Hi Alex, what you are seeing is the default behavior of various templates. In my view the templates are all very good and offer much convenience, but sometimes they only fit well in a specific context. For example, the transition from having gridlines to not having them doesn’t really make sense for templates that don’t enable gridlines by default. Regarding the issue with the scales, my approach is to either use a different template or just override the template’s attributes with update_layout or update_traces. Thank you.

1 Like

:jack_o_lantern: Halloween Night Dashboard — Cursor Powered :ghost:

• An interactive dashboard revealing Halloween night trick-or-treat trends across multiple years.
Built with Cursor, blending intelligent coding with fast, precise iteration.
• A dark, cinematic interface wrapped in deep black, orange, and red Halloween tones.
Smart filters for year, day of week, and time range enable focused exploration.
• Eye-catching KPI cards summarize total, average, and peak visitor counts.
• A glowing Bubble Heatmap shows when and where activity reached its height.
• A sleek Bar Chart highlights yearly changes and peak Halloween seasons.
Instant, real-time updates keep the data experience fluid and engaging.
• Designed with Plotly, Dash, and Bootstrap Darkly for modern responsiveness.

Code
import plotly.express as px
import plotly.graph_objects as go
from dash import Dash, dcc, html, Input, Output
import dash_bootstrap_components as dbc


# Load data
df = pd.read_csv("Halloween.csv")

# Parse and enrich fields
df["Date and Time"] = pd.to_datetime(df["Date and Time"], format="%m/%d/%y %H:%M")
df["Date"] = pd.to_datetime(df["Date"], format="%m/%d/%y")
df["Year"] = df["Date"].dt.year

# Ensure Time ordering
time_order = [
    "6:00pm",
    "6:30pm",
    "7:00pm",
    "7:30pm",
    "8:00pm",
    "8:15pm",
]
df["Time"] = pd.Categorical(df["Time"], categories=time_order, ordered=True)


# Define day order
day_order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
df['Day of Week'] = pd.Categorical(df['Day of Week'], categories=day_order, ordered=True)


# App setup with Bootstrap dark theme
app = Dash(__name__, external_stylesheets=[dbc.themes.DARKLY])
server = app.server

# Add responsive meta tag and custom CSS
app.index_string = '''
<!DOCTYPE html>
<html>
    <head>
        {%metas%}
        <title>{%title%}</title>
        {%favicon%}
        {%css%}
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <style>
            /* Custom dark theme overrides */
            .card {
                background-color: #141418 !important;
                border: 1px solid #2a2a33 !important;
            }
            .card-header {
                background-color: #1a1a1d !important;
                border-bottom: 1px solid #2a2a33 !important;
                color: #ececf1 !important;
            }
            
            /* Summary number highlight */
            .summary-number {
                color: #ffcc33; /* warm halloween yellow */
                font-weight: 700;
                font-size: 1.15rem;
            }
            
            /* Multi-select dropdown dark background */
            .Select-control {
                background-color: #000000 !important;
                border-color: #2a2a33 !important;
            }
            .Select-menu-outer {
                background-color: #000000 !important;
                border-color: #2a2a33 !important;
            }
            .Select-option {
                background-color: #000000 !important;
                color: #ececf1 !important;
            }
            .Select-option:hover {
                background-color: #141418 !important;
            }
            .Select-option.is-selected {
                background-color: #2a2a33 !important;
            }
            .Select-value-label {
                color: #ececf1 !important;
            }
            .Select-placeholder {
                color: #666 !important;
            }
            .Select-input > input {
                color: #ececf1 !important;
            }
            /* Year dropdown X close buttons - red */
            .Select-value-icon {
                color: #ff0000 !important;
            }
            .Select-value-icon:hover {
                color: #ff3333 !important;
            }
            /* RangeSlider styling - selected range is red */
            .rc-slider-rail {
                background-color: #444 !important;
            }
            .rc-slider-track {
                background-color: #ff0000 !important;
            }
            .rc-slider-handle {
                border-color: red !important;
            }
            .rc-slider-handle:hover {
                border-color: #ff3333 !important;
            }
            /* No auto animations; animations only via chart Play button */
        </style>
    </head>
    <body>
        {%app_entry%}
        <footer>
            {%config%}
            {%scripts%}
            {%renderer%}
        </footer>
    </body>
</html>
'''

background_color = "#0b0b0d"  # near-black
panel_color = "#141418"
text_color = "#ececf1"
orange = "#ff7a00"
purple = "#6a0dad"
green = "#3cff9a"


app.layout = dbc.Container(
    fluid=True,
    style={"backgroundColor": background_color, "minHeight": "100vh", "padding": "20px"},
    children=[
                # Header
                dbc.Row([
                    dbc.Col([
                        html.H1("Halloween Night Dashboard", className="mb-3", style={"color": orange, "margin": 0}),
                        html.P("Explore trick-or-treat counts by year, day, and time.", 
                               className="mb-4", style={"opacity": 0.8}),
                    ])
                ]),

                # KPI Stats Cards
                dbc.Row([
                    dbc.Col([
                        dbc.Card([
                            dbc.CardBody([
                                html.H4(id="kpi-total", className="mb-1", style={"color": "#ffffff", "fontWeight": "bold"}),
                                html.P("Total Count", className="mb-0", style={"opacity": 0.8, "fontSize": "0.9rem"})
                            ])
                        ], className="text-center")
                    ], width=3),
                    dbc.Col([
                        dbc.Card([
                            dbc.CardBody([
                                html.H4(id="kpi-avg", className="mb-1", style={"color": "#ffffff", "fontWeight": "bold"}),
                                html.P("Average Count", className="mb-0", style={"opacity": 0.8, "fontSize": "0.9rem"})
                            ])
                        ], className="text-center")
                    ], width=3),
                    dbc.Col([
                        dbc.Card([
                            dbc.CardBody([
                                html.H4(id="kpi-max", className="mb-1", style={"color": "#ffffff", "fontWeight": "bold"}),
                                html.P("Peak Count", className="mb-0", style={"opacity": 0.8, "fontSize": "0.9rem"})
                            ])
                        ], className="text-center")
                    ], width=3),
                    dbc.Col([
                        dbc.Card([
                            dbc.CardBody([
                                html.H4(id="kpi-best-time", className="mb-1", style={"color": "#ffffff", "fontWeight": "bold"}),
                                html.P("Peak Time", className="mb-0", style={"opacity": 0.8, "fontSize": "0.9rem"})
                            ])
                        ], className="text-center")
                    ], width=3),
                ], className="mb-4"),

                # Filters at the top
                dbc.Row([
                    dbc.Col([
                        dbc.Card([
                            dbc.CardHeader("Filters", style={"backgroundColor": "#1a1a1d"}),
                            dbc.CardBody([
                                dbc.Row([
                                    dbc.Col([
                                        dbc.Label("Year", className="fw-bold mb-2"),
                                        dcc.Dropdown(
                                            id="filter-year",
                                            options=[{"label": "All", "value": "all"}] + [{"label": str(y), "value": int(y)} for y in sorted(df["Year"].unique())],
                                            multi=True,
                                            value=["all"],  # Default to "all"
                                            placeholder="Select year(s)",
                                        ),
                                    ], width=4),
                                    dbc.Col([
                                        dbc.Label("Day of Week", className="fw-bold mb-2"),
                                        dcc.Dropdown(
                                            id="filter-dow",
                                            options=[{"label": "All", "value": "all"}] + [{"label": d, "value": d} for d in df["Day of Week"].unique()],
                                            multi=True,
                                            value=["all"],  # Default to "all"
                                            placeholder="Select days",
                                        ),
                                    ], width=4),
                                    dbc.Col([
                                        dbc.Label("Time Range", className="fw-bold mb-2"),
                                        dcc.RangeSlider(
                                            id="filter-time",
                                            min=0,
                                            max=len(time_order) - 1,
                                            step=1,
                                            value=[0, len(time_order) - 1],
                                            marks={i: t for i, t in enumerate(time_order)},
                                            allowCross=False,
                                        ),
                                    ], width=4),
                                ])
                            ])
                        ], className="mb-4")
                    ])
                ]),

                # Store for cross-filtering state
                dcc.Store(id="selected-data-store", data={"selected_year": None, "selected_day_time": None}),

                # Two Charts Side by Side
                dbc.Row([
                    dbc.Col([
                dbc.Card([
                    dbc.CardHeader("Bar Chart (with Trend)"),
                            dbc.CardBody([
                                dcc.Graph(id="graph-scatter", config={"displaylogo": False, "staticPlot": False})
                            ])
                        ])
                    ], width=6),
                    dbc.Col([
                        dbc.Card([
                    dbc.CardHeader("Bubble Heatmap"),
                            dbc.CardBody([
                                dcc.Graph(id="graph-heatmap", config={"displaylogo": False, "staticPlot": False})
                            ])
                        ])
                    ], width=6),
                ], className="mb-4"),

                # Summary Section
                dbc.Row([
                    dbc.Col([
                        dbc.Card([
                            dbc.CardHeader("Summary", style={"backgroundColor": "#1a1a1d"}),
                            dbc.CardBody([
                                html.Div(id="summary-content", style={"fontSize": "0.95rem", "lineHeight": "1.6"})
                            ])
                        ])
                    ])
                ]),
    ]
)


def dark_layout(fig):
    fig.update_layout(
        template="plotly_dark",
        paper_bgcolor=panel_color,
        plot_bgcolor=panel_color,
        font_color=text_color,
        margin=dict(l=40, r=20, t=50, b=40),
    )
    return fig


@app.callback(
    Output("graph-heatmap", "figure"),
    Output("graph-scatter", "figure"),
    Output("kpi-total", "children"),
    Output("kpi-avg", "children"),
    Output("kpi-max", "children"),
    Output("kpi-best-time", "children"),
    Output("summary-content", "children"),
    Input("filter-year", "value"),
    Input("filter-dow", "value"),
    Input("filter-time", "value"),
)
def update_figures(years, dows, time_idx_range):
    filtered = df.copy()
    
    # Handle "all" option for years
    if years:
        if "all" in years:
            # If "all" is selected, show all years (don't filter)
            pass
        else:
            # Filter out "all" from years list and filter by actual year values
            year_values = [y for y in years if y != "all" and isinstance(y, int)]
            if year_values:
                filtered = filtered[filtered["Year"].isin(year_values)]
    
    # Handle "all" option for days of week
    if dows:
        if "all" in dows:
            # If "all" is selected, show all days (don't filter)
            pass
        else:
            # Filter out "all" from dows list and filter by actual day values
            dow_values = [d for d in dows if d != "all" and isinstance(d, str)]
            if dow_values:
                filtered = filtered[filtered["Day of Week"].isin(dow_values)]
    
    start_i, end_i = time_idx_range
    valid_times = time_order[start_i : end_i + 1]
    filtered = filtered[filtered["Time"].isin(valid_times)]

    # Calculate KPIs
    total_count = filtered["Count"].sum()
    avg_count = filtered["Count"].mean() if len(filtered) > 0 else 0
    max_count = filtered["Count"].max() if len(filtered) > 0 else 0
    best_time_row = filtered.loc[filtered["Count"].idxmax()] if len(filtered) > 0 else None
    best_time = best_time_row["Time"] if best_time_row is not None else "N/A"

    # Bubble heatmap - Time vs Day of Week with circles
    # Get actual days present in data
    days_in_data = filtered["Day of Week"].unique()
    # Filter day_order to only include days present in data
    day_order_filtered = [day for day in day_order if day in days_in_data]
    
    pivot = (
        filtered.groupby(["Day of Week", "Time"], as_index=False)["Count"].sum()
        .pivot(index="Time", columns="Day of Week", values="Count")
        .reindex(index=time_order, columns=day_order_filtered)
    )
    
    # Prepare data for bubble chart (heatmap with circles)
    bubble_heatmap = go.Figure()
    
    # Colors for heatmap intensity
    max_value = pivot.values.max() if not pivot.empty else 250
    threshold_ratio = min(50 / max_value, 0.4) if max_value > 0 else 0.2
    max_val = max_value  # Maximum value for red highlighting
    
    # Create bubbles for each cell - only max is red, others with opacity
    for col_idx, col in enumerate(pivot.columns):
        for time_idx, time in enumerate(pivot.index):
            value = pivot.loc[time, col]
            if pd.notna(value) and value > 0:
                # Max value: red color
                if value == max_val:
                    color = "#ff0000"  # Red for maximum
                    opacity = 0.9  # More opaque
                    border_width = 1.5
                    border_color = 'rgba(255,255,255,0.6)'
                else:
                    # Other values: original colors with opacity
                    opacity = 0.6  # Opacity for other bubbles
                    border_width = 1.5
                    border_color = 'rgba(255,255,255,0.6)'
                    if value <= max_value * 0.25:
                        color = "white"
                    elif value <= max_value * 0.5:
                        color = "#e74c3c"  # Rich red
                    elif value <= max_value * 0.75:
                        color = "#f39c12"  # Warm orange
                    else:
                        color = "#ffcc33"  # Bright yellow-orange
                
                bubble_heatmap.add_trace(go.Scatter(
                    x=[time_idx],
                    y=[col_idx],  # Use column index instead of column name
                    mode='markers',
                    marker=dict(
                        size=value / max_value * 50 + 10,  # Scale bubble size
                        color=color,
                        line=dict(width=border_width, color=border_color),
                        opacity=opacity
                    ),
                    name=f"{col} - {time}",
                    hovertemplate=f"Day: {col}<br>Time: {time}<br>Count: {value:.0f}<extra></extra>",
                    showlegend=False
                ))
    
    # No trendline on bubble heatmap as requested
    
    bubble_heatmap.update_layout(
        title=dict(text="Bubble Heatmap: Activity by Day and Time", x=0.5, xanchor="center"),
        xaxis=dict(
            title="Time",
            tickmode='array',
            tickvals=list(range(len(time_order))),
            ticktext=time_order,
        ),
        yaxis=dict(
            title="Day of Week",
            tickmode='array',
            tickvals=list(range(len(day_order_filtered))),
            ticktext=day_order_filtered,
            autorange='reversed'  # Reverse y-axis so Monday is at top
        ),
        hovermode='closest',
        uirevision='constant'
    )
    bubble_heatmap = dark_layout(bubble_heatmap)

    # Bar chart with annotations - Year vs Total Count
    yearly_totals = filtered.groupby("Year")["Count"].sum().reset_index()
    yearly_totals = yearly_totals.sort_values("Year")
    
    bar_chart = go.Figure()
    
    # Find maximum value
    max_year_value = yearly_totals["Count"].max()
    
    # Create bar chart with vertical gradient (each bar has gradient from bottom to top)
    # Use colorscale for vertical gradient effect
    # Separate bars: non-max bars with normal border, max bar with thick border
    max_year = yearly_totals.loc[yearly_totals["Count"].idxmax(), "Year"]
    
    # Non-max bars - darker colors
    non_max_data = yearly_totals[yearly_totals["Year"] != max_year]
    if len(non_max_data) > 0:
        bar_chart.add_trace(go.Bar(
            x=non_max_data["Year"],
            y=non_max_data["Count"],
            name='Total Count by Year',
            marker=dict(
                color=non_max_data["Count"],
                colorscale=[[0, '#ffb366'], [0.5, '#ff9900'], [1, '#ff7700']],  # Orange gradient
                showscale=False,
                opacity=0.75,  # Darker opacity
                line=dict(width=0)  # No border for non-max bars
            ),
            text=[f"{int(count):,}" for count in non_max_data["Count"]],
            textposition='outside',
            textfont=dict(size=12),
            hovertemplate="Year: %{x}<br>Total Count: %{y:,}<extra></extra>"
        ))
    
    # Max bar - red color
    max_data = yearly_totals[yearly_totals["Year"] == max_year]
    if len(max_data) > 0:
        bar_chart.add_trace(go.Bar(
            x=max_data["Year"],
            y=max_data["Count"],
            name='Max Count',
            marker=dict(
                color='#ff0000',  # Red color for max
                opacity=0.9,  # More opaque
                line=dict(width=5, color='white')  # Thick white border
            ),
            text=[f"{int(count):,}" for count in max_data["Count"]],
            textposition='outside',
            textfont=dict(size=18, color='#ffffff'),  # Larger white text
            hovertemplate="Year: %{x}<br>Total Count: %{y:,}<extra></extra>"
        ))
    
    bar_chart.update_layout(
        title=dict(text="Total Count by Year", x=0.5, xanchor="center"),
        xaxis_title="Year",
        yaxis_title="Total Count",
        hovermode='closest',
        uirevision='constant',
        bargap=0
    )
    bar_chart = dark_layout(bar_chart)

    # Generate summary content in English
    # Handle "all" option when counting
    years_count = None
    if years:
        if "all" in years:
            years_count = "all"
        else:
            years_count = len([y for y in years if y != "all"])
    
    dows_count = None
    if dows:
        if "all" in dows:
            dows_count = "all"
        else:
            dows_count = len([d for d in dows if d != "all"])
    
    time_range_text = f"{time_order[start_i]} to {time_order[end_i]}" if time_idx_range else "all times"
    
    # Find trends
    year_trend = ""
    if len(filtered["Year"].unique()) > 1:
        yearly_totals = filtered.groupby("Year")["Count"].sum().sort_index()
        if len(yearly_totals) >= 2:
            first_year = yearly_totals.iloc[0]
            last_year = yearly_totals.iloc[-1]
            if last_year > first_year:
                year_trend = f"📈 {((last_year - first_year) / first_year * 100):.1f}% increase from {yearly_totals.index[0]} to {yearly_totals.index[-1]}"
            elif last_year < first_year:
                year_trend = f"📉 {((first_year - last_year) / first_year * 100):.1f}% decrease from {yearly_totals.index[0]} to {yearly_totals.index[-1]}"
            else:
                year_trend = f"➡️ Stable counts from {yearly_totals.index[0]} to {yearly_totals.index[-1]}"
    
    # Find peak day
    peak_day = filtered.groupby("Day of Week")["Count"].sum().idxmax() if len(filtered) > 0 else "N/A"
    
    # Find most active year
    most_active_year = filtered.groupby("Year")["Count"].sum().idxmax() if len(filtered) > 0 else "N/A"
    
    summary_content = [
        html.P([
            html.Strong("Data Overview: "), 
            "Showing data for ",
            (html.Span(f"{years_count}", className="summary-number") if years_count is not None else "all"),
            " year(s), ",
            (html.Span(f"{dows_count}", className="summary-number") if dows_count is not None else "all"),
            " day(s), from ",
            html.Span(time_range_text, className="summary-number"),
            "."
        ], className="mb-2"),
        
        html.P([
            html.Strong("Key Insights: "),
            "Peak activity occurs at ", html.Span(str(best_time), className="summary-number"),
            " with ", html.Span(f"{max_count:,}", className="summary-number"), " visitors. ",
            "The most active day is ", html.Span(str(peak_day), className="summary-number"),
            " and the busiest year was ", html.Span(str(most_active_year), className="summary-number"), "."
        ], className="mb-2"),
        
        html.P([
            html.Strong("Trends: "),
            year_trend if year_trend else "Single year data - no trend analysis available."
        ], className="mb-2"),
        
        html.P([
            html.Strong("Data Quality: "),
            "Total of ", html.Span(f"{len(filtered):,}", className="summary-number"), " data points with an average of ",
            html.Span(f"{avg_count:.1f}", className="summary-number"), " visitors per time slot."
        ], className="mb-0")
    ]

    return (
        bubble_heatmap, 
        bar_chart,
        html.Span(f"{total_count:,.0f}", style={"fontWeight": "bold"}),
        html.Span(f"{avg_count:.1f}", style={"fontWeight": "bold"}),
        html.Span(f"{max_count:,.0f}", style={"fontWeight": "bold"}),
        html.Span(str(best_time), style={"fontWeight": "bold"}),
        summary_content
    )


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