Figure Friday 2025 - week 51

What is the most common adoptable shelter dog?

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

Things to consider:

  • would you use a different graph than the one below?
  • would you like to tell a different data story using a Dash app?
  • how would you explore the data differently with Plotly Studio?

Below are screenshots of the app created by Plotly Studio on top of the dataset:

Prompt for the second bar chart:

Bar chart comparing dog characteristics

Chart:
- Type: Bar
- X: Characteristic category
- Y: Count or percentage
- Color: Category value or None (via Dropdown)

Data:
- Grouped by selected characteristic (Age, Size, Sex, Coat type)
- Count of dogs per category
- Calculated percentages of total

Options:
- Dropdown to select characteristic (Age, Size, Sex, Coat) - Default Age
- Dropdown to display metric (Count, Percentage) - Default Count
- Dropdown to sort order (Descending, Ascending) - Default Descending

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 The Pudding for the data.

3 Likes

This app explores various attributes of shelter dogs. Here is a Plotly Studio link to it:

Filters along the top reduce the data reported in 5 Mantine cards and in the visualizations below them. The filters select one or more US states, age ranges, primary breed and dog names.

polars dynamic_group_by aggregates the data into 1 month intervals before plotting. A log scale on the y-axis improves dynamic range and readability. I added an annotation to the last point on the right, using a single point graph objects scatter plot with its y- value annotated to that point. Having this number visible helps when y is logarithmic.

The choropleth shows the number of dogs by state after filterering.

The 2 Pareto charts show the most popular female and male dog names after filtering.

The Dash AG table at the bottom is not pre-filtered, however it has floating filters for users to find the data they want.

I enjoyed growing my Mantine card skills and like this subject matter because dogs make me happy.

Have a wonderful Christmas, keep an eye out for Santa Paws!

I used Plotly Studio for inspiration and ideas but wrote most of the source code myself with minor help from LLMs. Here is the code:

import polars as pl
import os
import plotly.express as px
import plotly.graph_objects as go
from dash import Dash, dcc, html, Input, Output
import dash_mantine_components as dmc
import dash_ag_grid as dag

#----- GLOBALS -----------------------------------------------------------------
root_file = 'allDogDescriptions'
style_horizontal_thick_line = {'border': 'none', 'height': '4px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 32}
style_horizontal_thin_line = {'border': 'none', 'height': '2px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 12}
style_h2 = {'text-align': 'center', 'font-size': '40px', 
            'fontFamily': 'Arial','font-weight': 'bold', 'color': 'gray'}
style_h3 = {'text-align': 'center', 'font-size': '24px', 
            'fontFamily': 'Arial','font-weight': 'normal', 'color': 'gray'}
style_card = {'text-align': 'center', 'font-size': '20px', 
            'fontFamily': 'Arial','font-weight': 'normal'}

# Responsive grid span for stat cards
dmc_card_span = {"base": 12, "sm": 6, "md": 2}
dmc_card_span = {"base": 12, "sm": 6, "md": 2}
#-----  FUNCTIONS --------------------------------------------------------------
def stat_card(title, value, id_prefix=None):
    '''Accessible, responsive stat card.
    title: label shown at top-left
    value: initial value string (can be empty, will show N/A)
    id_prefix: sets the value text id as f'{id_prefix}-info' to match callbacks
    '''
    value_txt = (
        f'{value:,}' if isinstance(value, (int, float)) 
        else (value if value not in (None, '') else 'N/A')
    )
    value_id = f'{id_prefix}-info' if id_prefix else None

    header = dmc.Group(
        justify='space-between',
        align='flex-start',
        children=[
            dmc.Text(title, size='xl', fw=600, c='dimmed'),
        ]
    )

    # Right-side content stack (title + value)
    content_stack = dmc.Stack([
        header,
        dmc.Space(h=4),
        dmc.Text(
            value_txt, 
            id=value_id, 
            size='xl', 
            fw=700, 
            style={'lineHeight': '1.1'}
        ),
    ], gap=0)

    # Left vertical accent bar
    accent_bar = html.Div(style={
        'width': '6px',
        'borderRadius': '4px',
        'background': 'linear-gradient(to bottom, #007bff, #ff7b00)'
    })

    # Row layout with accent bar + content
    row = html.Div([
        accent_bar,
        html.Div(content_stack, style={'flex': '1 1 auto'})
    ], style={'display': 'flex', 'gap': '12px', 'alignItems': 'stretch'})

    return dmc.Card(
        withBorder=True,
        shadow='sm',
        radius='md',
        padding='md',
        **{'aria-label': f'{title} statistic'},
        children=row
    )

def normalize_selection(selected_value, all_values_list):
    ''' Normalize dropdown/multiselect to handle ALL and ensure list type
    Input Args:
        selected_value: from dropdown/multiselect (can be string, list, or None)
        all_values_list: list of all possible values
    Returns:
        A list of selected values with 'ALL' properly handled
    '''
    # Handle None or empty list
    if selected_value is None or selected_value == []:
        return all_values_list
    
    # Handle 'ALL' as a string or as the only member of a list
    if selected_value == 'ALL' or selected_value == ['ALL']:
        return all_values_list
    
    # Handle list with 'ALL' in it
    if isinstance(selected_value, list):
        if 'ALL' in selected_value:
            # If list has ALL and other items, remove ALL
            filtered = [s for s in selected_value if s != 'ALL']
            return filtered if filtered else all_values_list
        return selected_value
    
    # Handle single string value
    return [selected_value]

def get_timeline_plot(df_filtered):
    # Create a timeline plot of dog postings over time
    df_time = (
        df_filtered
        .sort('DATE')             # sort before dynamic_group_by is a must
        .group_by_dynamic(
            index_column='DATE',  # specify the datetime column
            every='1mo',          # interval size
            period='1mo',         # window size
            closed='left'         # interval includes the left endpoint
        )
        .agg(pl.col('ID').count().alias('Dog Count'))   
    )
    fig = px.line(
        df_time,
        x='DATE', 
        y='Dog Count',
        title='Dog Postings Over Time',
        labels={'DATE': 'Month', 'Dog Count': 'Number of Dogs Posted'},
        markers=True
    )
    fig.update_layout(template='plotly_white', yaxis_type='log')
    # Extract last timeline point as Python scalars (Polars -> Python)
    last_date = df_time.select(pl.col('DATE').last()).item()
    last_count = int(df_time.select(pl.col('Dog Count').last()).item())

    # Add a marker + label using Plotly Graph Objects
    fig.add_trace(
        go.Scatter(
            x=[last_date],
            y=[last_count],
            mode='markers+text',
            text=[f'{last_count:,}  '],
            textposition='middle left',
            textfont=dict(size=16, color='blue'),
            marker=dict(size=8, color='gray'),
            hoverinfo='skip',
            showlegend=False,
            name=''
        )
    )
    return fig

def get_top_age_group(df):
    if df.height:
        return(
            df.get_column('AGE')
            .value_counts()
            .sort('count', descending=True)
            .item(0, 'AGE')
        )
    else:
        return('N/A')

def get_dog_name_pareto(df, gender):
    df_gender = (
        df
        .filter(pl.col('SEX') == gender)
        .group_by('NAME')
        .agg(NAME_COUNT = pl.col('ID').len())
        .select('NAME', 'NAME_COUNT')
        .sort('NAME_COUNT', descending=True)
    )
    if len(df_gender) > 10:
        df_gender = df_gender.head(10)

    fig = px.bar(
        df_gender, # .sort('NAME_COUNT'),
        y='NAME',
        x='NAME_COUNT',
        orientation='h',
        template='simple_white',
        title=f'Top 10 {gender} dog names',
        text=df_gender['NAME_COUNT'],
        labels={
        'NAME': '',
        }
    )
    fig.update_yaxes(autorange="reversed")
    fig.update_xaxes(
        showticklabels=False,
        ticks='',
        showline=False,
        title_text=''
    )
    return fig

def get_choropleth(df_filtered):
    # Create a choropleth map of dog counts by state
    df_state = (
        df_filtered
        .group_by('CONTACT_STATE')
        .agg(pl.col('ID').count().alias('Dog Count'))
    )
    fig = px.choropleth(
        df_state,
        locations='CONTACT_STATE',
        locationmode='USA-states',
        color='Dog Count',
        scope='usa',
        title='Dog Counts by State',
        labels={'CONTACT_STATE': 'State', 'Dog Count': 'Number of Dogs'}
    )
    fig.update_layout(template='plotly_white')  
    return fig

#----- LOAD AND CLEAN DATA -----------------------------------------------------
root_file = 'allDogDescriptions'
if os.path.exists(root_file + '.parquet'):
    df = pl.read_parquet(root_file + '.parquet')
else:
    # Define Enum categories
    enum_AGE = pl.Enum(['Adult', 'Baby', 'Senior', 'Young'])
    enum_SEX = pl.Enum(['Male', 'Female', 'Unknown'])
    enum_SIZE = pl.Enum(['Small', 'Medium', 'Large','Extra Large'])
    df = (
        pl.read_csv(root_file + '.csv', ignore_errors=True)
        # Only all dog names with letters a-z or whitespace
        .filter(pl.col('name').str.contains(r'^[a-zA-Z\s]+$'))
        .select(
            NAME = pl.col('name').str.strip_chars().str.to_titlecase(),
            CONTACT_CITY = pl.col('contact_city'),
            CONTACT_STATE = pl.col('contact_state'),
            CONTACT_ZIP = pl.col('contact_zip').cast(pl.UInt32),
            BREED_PRIMARY = pl.col('breed_primary'),
            BREED_MIXED = pl.col('breed_mixed'),
            AGE = pl.col('age').cast(enum_AGE),
            SEX = pl.col('sex').cast(enum_SEX),
            SIZE = pl.col('size').cast(enum_SIZE),
            FIXED = pl.col('fixed'),
            HOUSE_TRAINED = pl.col('house_trained'),
            SHOTS_CURRENT = pl.col('shots_current'),
            DATE = pl.col('posted')   # regex for dates formatted as YYYY-MM-DD
                .str.extract(r'(\d{4}-\d{2}-\d{2})', group_index=1)
                .str.to_date('%Y-%m-%d'),
            TIME = pl.col('posted')   # regex for time formatted as HH:MM:SS
                .str.extract(r'(\d{2}:\d{2}:\d{2})', group_index=1)
                .str.to_time('%H:%M:%S'),
            ID = pl.col('id').cast(pl.UInt32),
            ORG_ID = pl.col('org_id'),
        )
        .filter(  # regex to accept states comprised of 2 uppercase letters    
            pl.col('CONTACT_STATE').str.contains(r'^[A-Z]{2}$')
        )
    )
    df.write_parquet(root_file + '.parquet')

#----- GLOBAL LISTS ------------------------------------------------------------
contact_states = sorted(df.unique('CONTACT_STATE')['CONTACT_STATE'].to_list())
primary_breeds = sorted(df.unique('BREED_PRIMARY')['BREED_PRIMARY'].to_list())
animal_age_list = ['Baby', 'Young', 'Adult','Senior']
dog_name_list = sorted(df.unique('NAME')['NAME'].to_list())

#----- DASH COMPONENTS------ ---------------------------------------------------
dcc_select_contact_state = (
    dcc.Dropdown(
        placeholder='Select Contact State(s)', 
        options=['ALL'] + contact_states, # menu choices  
        value='ALL', # initial value              
        clearable=True, searchable=True, multi=True, closeOnSelect=False,
        style={'fontSize': '18px'},
        id='id_select_contact_state'
    )
)
dcc_select_animal_age = (
    dcc.Dropdown(
        placeholder='Select Animal Age(s)', 
        options=['ALL'] + animal_age_list, # menu choices  
        value='ALL', # initial value              
        clearable=True, searchable=True, multi=True, closeOnSelect=False,
        style={'fontSize': '18px'},
        id='id_select_animal_age'
    )
)
dcc_select_primary_breed = (
    dcc.Dropdown(
        placeholder='Select Primary Breed(s)', 
        options=['ALL'] + primary_breeds, # menu choices  
        value='ALL', # initial value              
        clearable=True, searchable=True, multi=True, closeOnSelect=False,
        style={'fontSize': '18px'},
        id='id_select_primary_breed'
    )
)

# Dash Core Dropdown for dog name selection
dcc_select_dog_name = (
    dcc.Dropdown(
        placeholder='Select Dog Names', 
        options=['ALL'] + dog_name_list, # menu choices  
        value='ALL', # initial value              
        clearable=True, searchable=True, multi=True, closeOnSelect=False,
        style={'fontSize': '18px'},
        id='id_select_dog_name'
    )
)

# Dash AG Grid Table for full df
def get_ag_grid_table(df):
    # Build columnDefs without floatingFilter
    column_defs = []
    for col in df.columns:
        col_def = {"headerName": col, "field": col, "floatingFilter": True}
        column_defs.append(col_def)
    return dag.AgGrid(
        id="ag-table-full-df",
        columnDefs=column_defs,
        rowData=df.to_dicts(),
        defaultColDef={"filter": True, "sortable": True, "resizable": True},
        style={"height": "500px", "width": "100%"}
    )
#----- DASH APPLICATION STRUCTURE ----------------------------------------------

app = Dash()
server = app.server
app.layout =  dmc.MantineProvider([
    html.Hr(style=style_horizontal_thick_line),
    dmc.Text('Furry Friday: Shelter Animal Analytics Dashboard', ta='center', style=style_h2),
    dmc.Text(
        'Comprehensive analysis of shelter animal intake, demographics,',
        ta='center', style=style_h3
    ),
    dmc.Text(
        'health status, and organizational performance across 58,147 records ' +
        'spanning 2003-2019.', ta='center', style=style_h3
    ),
    dmc.Space(h=30),
    html.Hr(style=style_horizontal_thick_line),
    dmc.Space(h=30),
    dmc.Grid(children =  [
        dmc.GridCol(dmc.Text('State(s)', ta='left'), span=2, offset=2),
        dmc.GridCol(dmc.Text('Age Range', ta='left'), span=2, offset=0),
        dmc.GridCol(dmc.Text('Primary Breed', ta='left'), span=2, offset=0),
        dmc.GridCol(dmc.Text('Dog Name', ta='left'), span=2, offset=0),
    ]),
    dmc.Space(h=10),
    dmc.Grid(
        children = [  
            dmc.GridCol(dcc_select_contact_state, span=2, offset=2),
            dmc.GridCol(dcc_select_animal_age, span=2, offset=0),
            dmc.GridCol(dcc_select_primary_breed, span=2, offset=0),
            dmc.GridCol(html.Div([dcc_select_dog_name,]),span=2, offset=0)
        ],
    ),
    dmc.Space(h=30),
    dmc.Grid(children = [      # Summary cards row (responsive spans)
        dmc.GridCol(stat_card('Dog Count', '', id_prefix='dog-count'), span=dmc_card_span, offset=1),
        dmc.GridCol(stat_card('Top Age Group', '', id_prefix='top-age-group'), span=dmc_card_span),
        dmc.GridCol(stat_card('Fixed', '', id_prefix='fixed'), span=dmc_card_span),
        dmc.GridCol(stat_card('Shots Current', '', id_prefix='shots-current'), span=dmc_card_span),
        dmc.GridCol(stat_card('Organizations', '', id_prefix='organizations'), span=dmc_card_span),
    ]),
    dmc.Space(h=30),
    html.Hr(style=style_horizontal_thin_line),
        dmc.Grid(children =  [
        dmc.GridCol(dmc.Text('Visualizations filtered by the selections above.', 
            ta='center'), span=10, offset=1),
    ]),
    dmc.Grid(children = [
        dmc.GridCol(dcc.Graph(id='timeline-plot'), span=5, offset=1), 
        dmc.GridCol(dcc.Graph(id='choropleth-map'), span=5, offset=1),           
    ]),
    dmc.Grid(children = [
        dmc.GridCol(dcc.Graph(id='pareto-female'), span=5, offset=1),    
        dmc.GridCol(dcc.Graph(id='pareto-male'), span=5, offset=1),      
    ]),
    html.Hr(style=style_horizontal_thin_line),
        dmc.Grid(children =  [
        dmc.GridCol(dmc.Text('Raw data table with floating filters', 
            ta='center'), span=10, offset=1),
    ]),
    dmc.Grid(children = [
        dmc.GridCol(get_ag_grid_table(df), span=10, offset=1),        
    ]),

])
@app.callback(
    Output('dog-count-info', 'children'),
    Output('top-age-group-info', 'children'),
    Output('fixed-info', 'children'),
    Output('shots-current-info', 'children'),
    Output('organizations-info', 'children'),
    Output('timeline-plot', 'figure'), 
    Output('choropleth-map', 'figure'),  
    Output('pareto-female', 'figure'),  
    Output('pareto-male', 'figure'),
    Input('id_select_contact_state', 'value'),
    Input('id_select_animal_age', 'value'),
    Input('id_select_primary_breed', 'value'),
    Input('id_select_dog_name', 'value')
    )
def callback(selected_states, selected_animal_age, selected_primary_breed, selected_dog_name):
    # Normalize all selections
    selected_states = normalize_selection(selected_states, contact_states)
    selected_animal_age = normalize_selection(selected_animal_age, animal_age_list)
    selected_primary_breed = normalize_selection(selected_primary_breed, primary_breeds)
    selected_dog_name = normalize_selection(selected_dog_name, dog_name_list)
    
    # Filter dataframe based on selections
    df_filtered = ( df
        .filter(pl.col('CONTACT_STATE').is_in(selected_states))
        .filter(pl.col('AGE').is_in(selected_animal_age))
        .filter(pl.col('BREED_PRIMARY').is_in(selected_primary_breed))
        .filter(pl.col('NAME').is_in(selected_dog_name))
    )
    dog_count = df_filtered.height
    top_age_group = get_top_age_group(df_filtered)
    fixed_count = df_filtered.filter(pl.col('FIXED')).height
    fixed_pct = round(100 * fixed_count / dog_count, 1) if dog_count else 0.0
    shots_count = df_filtered.filter(pl.col('SHOTS_CURRENT')).height
    shots_pct = round(100 * shots_count / dog_count, 1) if dog_count else 0.0 
    org_count = df_filtered.select(pl.col('ORG_ID')).unique().height
    return (
        f'{dog_count:,}',
        top_age_group,
        f'{fixed_count:,} ({fixed_pct}%)',
        f'{shots_count:,} ({shots_pct}%)',
        f'{org_count:,}',
        get_timeline_plot(df_filtered),
        get_choropleth(df_filtered),
        get_dog_name_pareto(df_filtered, 'Female'),
        get_dog_name_pareto(df_filtered, 'Male')
    )
if __name__ == '__main__':
    app.run(debug=True)

Here is a screenshot of the top portion:

6 Likes

Max and Buddy are the most famous male dog names :slight_smile: I’m not surprised @Mike_Purtell .
But Bella and Daisy I didn’t expect for most famous female dog names.

If I ever get a dog, I think I’ll name it Midnight ( it will be a black dog :slight_smile: )

I really like the data app you made, @Mike_Purtell . A cool additional interactivity feature would be to have the DAG filtering update the graphs above. So if we filter the table to only see adult dogs, that would be the data used for the rest of the data app.

Thanks for sharing this, Mike.

2 Likes

I developed this Rescue Dog Analytics dashboard using Plotly Dash within VS Code to transform shelter data into a highly visual and interactive experience.

The project features a dynamic filtering system that allows for real-time data drilling by state, breed, and gender, providing immediate updates to all visualizations.

A key highlight is the integrated theme engine, which enables users to switch between different color palettes that automatically adjust the UI and Plotly charts for better accessibility and style.

The dashboard provides deep insights through data visualizations, including a US choropleth map, a cross-tabulated heatmap of age and size, and a chronological activity timeline.

Built with dbc and dcc components, the application maintains a professional and responsive layout, ensuring that critical KPIs like neutered rates and special needs counts are clearly highlighted.

Summary
import dash
from dash import dcc, html, Input, Output
import dash_bootstrap_components as dbc
import pandas as pd
import plotly.express as px

# --- Adatok betöltése ---
df = pd.read_csv('allDogDescriptions.csv')
df['posted'] = pd.to_datetime(df['posted'], errors='coerce')
df['posted_date'] = df['posted'].dt.date

state_map = {
    'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas', 'CA': 'California',
    'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware', 'FL': 'Florida', 'GA': 'Georgia',
    'HI': 'Hawaii', 'ID': 'Idaho', 'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa',
    'KS': 'Kansas', 'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
    'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota', 'MS': 'Mississippi', 'MO': 'Missouri',
    'MT': 'Montana', 'NE': 'Nebraska', 'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey',
    'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina', 'ND': 'North Dakota', 'OH': 'Ohio',
    'OK': 'Oklahoma', 'OR': 'Oregon', 'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
    'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah', 'VT': 'Vermont',
    'VA': 'Virginia', 'WA': 'Washington', 'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming'
}

df['contact_state'] = df['contact_state'].str.replace(r'\d+', '', regex=True).str.strip().str.upper()
df['full_state'] = df['contact_state'].map(state_map).fillna(df['contact_state'])

FA = "https://use.fontawesome.com/releases/v5.15.4/css/all.css"
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP, FA])

THEMES = {
    "Natural Brown": {
        "bg": "#f4f1ea", "panel": "#ffffff", "text": "#4b3621", 
        "accent": "#8b5a2b", "scale": "Brwnyl",
        "kpi": "linear-gradient(135deg, #3d2b1f, #a68966)"
    },
    "Ocean Blue": {
        "bg": "#f0f8ff", "panel": "#ffffff", "text": "#004e92", 
        "accent": "#0074d9", "scale": "Blues",
        "kpi": "linear-gradient(135deg, #004e92, #000428)"
    }
}

DOG_IMAGE = "https://i.pinimg.com/originals/22/5f/1b/225f1b2a67dfcb0cb943c587950236c1.jpg"

app.layout = html.Div(id="outer-container", style={"minHeight": "100vh"}, children=[
    dbc.Container(fluid=True, children=[
        dbc.Row([
            # BAL OLDAL: SZÉLESEBB SZŰRŐ PANEL (lg=3)
            dbc.Col([
                html.Div(id="filter-panel", style={
                    "padding": "25px", "height": "100vh", "position": "sticky", "top": "0",
                    "box-shadow": "4px 0 15px rgba(0,0,0,0.05)", "display": "flex", "flexDirection": "column"
                }, children=[
                    html.Div([
                        html.Div([
                            html.H4([html.I(className="fas fa-paw me-2"), "Rescue"], className="fw-bold d-inline"),
                            dbc.Button([html.I(className="fas fa-undo")], id="reset-btn", color="link", 
                                       className="float-end p-0 text-decoration-none", style={"fontSize": "0.9rem"})
                        ], className="mb-4"),
                        
                        html.Div([
                            html.Label("Theme Selection", className="fw-bold extra-small mb-2"),
                            dbc.RadioItems(
                                id='palette-filter',
                                options=[{'label': k, 'value': k} for k in THEMES.keys()],
                                value='Natural Brown',
                                label_style={'display': 'block', 'fontSize': '0.85rem', 'marginBottom': '5px'},
                                input_style={"marginRight": "10px"}
                            ),
                        ], className="mb-4"),

                        html.Div([
                            html.Label("State Filter", className="fw-bold extra-small mb-2"),
                            dcc.Dropdown(id='state-filter', 
                                         options=[{'label': s, 'value': s} for s in sorted(df['full_state'].unique())], 
                                         multi=True, placeholder="All States", className="small-dropdown"),
                        ], className="mb-3"),

                        html.Div([
                            html.Label("Breed Filter", className="fw-bold extra-small mb-2"),
                            dcc.Dropdown(id='breed-filter', multi=True, placeholder="All Breeds", className="small-dropdown"),
                        ], className="mb-3"),

                        html.Div([
                            html.Label("Gender Selection", className="fw-bold extra-small mb-2"),
                            dbc.Checklist(id='sex-filter',
                                         options=[{'label': s, 'value': s} for s in df['sex'].unique()],
                                         value=df['sex'].unique().tolist(),
                                         label_style={"display": "block", "fontSize": "0.85rem", "marginBottom": "5px"},
                                         input_style={"marginRight": "10px"}),
                        ], className="mb-4"),
                        
                    ], style={"overflowY": "auto", "flex": "1"}),

                    html.Img(src=DOG_IMAGE, style={"width": "100%", "borderRadius": "12px", "opacity": "0.9", "marginTop": "20px"})
                ])
            ], lg=3, md=4, style={"padding": "0"}),

            # JOBB OLDAL: TARTALOM (lg=9)
            dbc.Col([
                html.Div(style={"padding": "2rem"}, children=[
                    dbc.Row([dbc.Col(html.H2("Rescue Dog Analytics", id="main-title", className="fw-bold"), width=12)], className="mb-4"),

                    # Kompakt KPI-k
                    dbc.Row([
                        dbc.Col(dbc.Card(dbc.CardBody([html.H3(id="kpi-total", className="fw-bold"), html.Small("Total Listings")]), id="kpi-1", className="border-0 text-white text-center shadow-sm")),
                        dbc.Col(dbc.Card(dbc.CardBody([html.H3(id="kpi-fixed", className="fw-bold"), html.Small("Neutered Rate")]), id="kpi-2", className="border-0 text-white text-center shadow-sm")),
                        dbc.Col(dbc.Card(dbc.CardBody([html.H3(id="kpi-special", className="fw-bold"), html.Small("Special Needs")]), id="kpi-3", className="border-0 text-white text-center shadow-sm")),
                    ], className="g-3 mb-4"),

                    # Fő grafikonok
                    dbc.Row([
                        dbc.Col(html.Div(dcc.Graph(id='map-chart', style={"height": "350px"}), className="p-3 rounded-3 bg-white shadow-sm"), lg=7),
                        dbc.Col(html.Div(dcc.Graph(id='age-size-heat', style={"height": "350px"}), className="p-3 rounded-3 bg-white shadow-sm"), lg=5),
                    ], className="g-3 mb-4"),

                    # Timeline
                    dbc.Row([
                        dbc.Col(html.Div([
                            html.P("Activity Trends (Posted Date)", className="fw-bold mb-0 ps-3 pt-3", style={"fontSize": "0.95rem"}),
                            dcc.Graph(id='timeline-chart', style={"height": "280px"})
                        ], className="bg-white rounded-3 shadow-sm"), width=12)
                    ])
                ])
            ], lg=9, md=8)
        ])
    ])
])

# CSS javítás a dcc Dropdown és a reszponzivitás érdekében
app.index_string = '''
<!DOCTYPE html>
<html>
    <head>
        {%metas%}
        <title>Rescue Dashboard</title>
        {%favicon%}
        {%css%}
        <style>
            /* Dropdown vizuális finomítás */
            .small-dropdown .Select-control { 
                min-height: 38px !important; 
                font-size: 0.9rem !important; 
                border-radius: 8px !important;
            }
            .extra-small { 
                font-size: 0.7rem; 
                text-transform: uppercase; 
                color: #888; 
                letter-spacing: 0.8px; 
            }
            /* Görgetősáv stílus a szűrőpanelhez */
            #filter-panel::-webkit-scrollbar { width: 5px; }
            #filter-panel::-webkit-scrollbar-thumb { background: #ccc; border-radius: 10px; }
        </style>
    </head>
    <body>
        {%app_entry%}
        <footer>
            {%config%}
            {%scripts%}
            {%renderer%}
        </footer>
    </body>
</html>
'''

# --- CALLBACKS ---

@app.callback(
    Output('breed-filter', 'options'),
    Input('state-filter', 'value')
)
def update_breed_dropdown(selected_states):
    if not selected_states:
        relevant_breeds = sorted(df['breed_primary'].unique())
    else:
        relevant_breeds = sorted(df[df['full_state'].isin(selected_states)]['breed_primary'].unique())
    return [{'label': b, 'value': b} for b in relevant_breeds]

@app.callback(
    [Output('state-filter', 'value'), Output('breed-filter', 'value'), 
     Output('sex-filter', 'value'), Output('palette-filter', 'value')],
    Input('reset-btn', 'n_clicks'),
    prevent_initial_call=True
)
def reset_all_filters(n):
    return None, None, df['sex'].unique().tolist(), 'Natural Brown'

@app.callback(
    [Output('kpi-total', 'children'), Output('kpi-fixed', 'children'), Output('kpi-special', 'children'),
     Output('map-chart', 'figure'), Output('age-size-heat', 'figure'), Output('timeline-chart', 'figure'),
     Output('kpi-1', 'style'), Output('kpi-2', 'style'), Output('kpi-3', 'style'),
     Output('outer-container', 'style'), Output('filter-panel', 'style'), Output('main-title', 'style')],
    [Input('state-filter', 'value'), Input('breed-filter', 'value'), 
     Input('sex-filter', 'value'), Input('palette-filter', 'value'),
     Input('map-chart', 'clickData')]
)
def update_content(states, breeds, sexes, theme_name, clickData):
    dff = df[df['sex'].isin(sexes)]
    
    current_states = states
    if clickData:
        clicked_state_code = clickData['points'][0]['location']
        clicked_state_full = state_map.get(clicked_state_code)
        if clicked_state_full:
            current_states = [clicked_state_full]

    if current_states: dff = dff[dff['full_state'].isin(current_states)]
    if breeds: dff = dff[dff['breed_primary'].isin(breeds)]

    t = THEMES[theme_name]
    
    total = f"{len(dff):,}"
    fixed = f"{(dff['fixed'].astype(float).mean()*100):.1f}%" if not dff.empty else "0%"
    special = f"{len(dff[dff['special_needs'] == True]):,}"

    f_map = px.choropleth(dff['contact_state'].value_counts().reset_index(), locations='contact_state', 
                          locationmode="USA-states", color='count', scope="usa", color_continuous_scale=t["scale"])
    f_heat = px.imshow(pd.crosstab(dff['age'], dff['size']), text_auto=True, color_continuous_scale=t["scale"])
    f_time = px.area(dff.groupby('posted_date').size().reset_index(name='count'), x='posted_date', y='count', color_discrete_sequence=[t["accent"]])

    for f in [f_map, f_heat, f_time]:
        f.update_layout(paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', 
                        font=dict(color=t["text"], size=10), margin=dict(t=10, b=10, l=10, r=10))
    
    f_map.update_layout(margin=dict(t=0, b=0, l=0, r=0))
    f_map.update_coloraxes(showscale=False) # Tisztább térkép

    kpi_style = {"background": t["kpi"], "borderRadius": "15px", "border": "none", "padding": "20px"}
    panel_style = {"background-color": t["panel"], "color": t["text"], "padding": "25px", 
                   "height": "100vh", "position": "sticky", "top": "0", "display": "flex", "flexDirection": "column"}

    return (total, fixed, special, f_map, f_heat, f_time,
            kpi_style, kpi_style, kpi_style, 
            {"background-color": t["bg"]}, panel_style, 
            {"color": t["text"], "fontWeight": "800", "fontSize": "2.2rem"})

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

Very nice, @Ester .

I like how you give the user the option to choose between table view and chart view and how we can focus on a specific state of interest. Great idea adding the reset button at the top left :+1:

2 Likes

@adamschroeder Thank you! The reset button is not good yet, I will fix it soon, because if I click on a state it works, but the reset does not update it yet.
I also messed up the date, I corrected it only for 2019.

1 Like

But when I clicked the reset button on the top left corner twice it worked. For me it reset the other filters as well.

1 Like

Yes, I wanted to make click data on the map but iI deleted it somehow. I will make it later and the reset button with it.

2 Likes