Figure Friday 2025 - week 47

How do discounts affect sales?

Answer this question and a few others by using Plotly on the Sales dataset
(thank you @Ester for helping with the 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 this dataset:

Prompt for the Treemap:

Sales breakdown by segment and country

Chart:
- Type: Treemap or Sunburst (selectable by dropdown)
- Path: Segment → Country (hierarchical)
- Values: Sum of sales (`Sales`)

Data:
- Grouped by segment hierarchy (Segment → Country), sum of sales
- Filtered by date range

Options:
- Dropdown to select chart type (Treemap, Sunburst) - Default Treemap
- Dropdown to select metric (Total Sales, Total Quantity, Total Profit) - Default Total Sales
- Dropdown to select date range (Latest month, Latest quarter, Latest year, All dates) - Default All dates

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 Workout Wednesday for the data.

1 Like

This dashboard provides three timeline plots for exploring monthly aggregated sales by country, using a Polars dynamic_group_by operation.

On the left, the visualizations focus on a single country, selected via the dmc.Select tool.

  • The top-left plot shows total monthly sales for the chosen country. It includes a neat visual effect: green shading for values above the mean and red shading for values below it.
  • The bottom-left plot displays cumulative sales for that country, broken out by the three market segments in the dataset.

On the right, the visualizations support multiple countries, selected with the dmc.MultiSelect tool.

  • The top-right plot shows cumulative sales for each selected country.
  • The bottom-right visualization is a choropleth map highlighting their locations.

You can change the map projection using the dropdown above the map—there are more than 80 projection styles available in the Plotly library.

Have fun exploring!

Here is the link to the app on Plotly Cloud:

Here is the code:


import pycountry  # to get ISO-3 codes for each country in the dataset
import polars as pl
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')

'''
Top-left (TL): pick one country, timeline showing total sales of selected country
          include horizontal bar to show mean values
          color green above mean, red below mean
Top-right(TR): pick on or more countries, cumulative sales on timeline
Bottom-left(BL): show total sales of country in Top-left, with group by market segment
Bottom-right(BR): choropleth to sales by country
 one segment, timeline showing total sales of selected s
'''

#----- 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'}
style_h3 = {'text-align': 'center', 'font-size': '16px', 
            'fontFamily': 'Arial','font-weight': 'bold', 'color': 'gray'}

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

choro_projections = sorted(['airy', 'aitoff', 'albers', 'august',
    'azimuthal equal area', 'azimuthal equidistant', 'baker',
    'bertin1953', 'boggs', 'bonne', 'bottomley', 'bromley',
    'collignon', 'conic conformal', 'conic equal area', 'conicequidistant', 
    'craig', 'craster', 'cylindrical equal area', 'cylindrical stereographic', 
    'eckert1', 'eckert2', 'eckert3', 'eckert4', 'eckert5', 'eckert6', 'eisenlohr',
    'equal earth', 'equirectangular', 'fahey', 'foucaut', 'foucaut sinusoidal', 
    'ginzburg4', 'ginzburg5', 'ginzburg6', 'ginzburg8', 'ginzburg9', 'gnomonic',
    'gringorten', 'gringorten quincuncial', 'guyou', 'hammer', 'hill', 
    'homolosine', 'hufnagel', 'hyperelliptical', 'kavrayskiy7', 'lagrange', 
    'larrivee', 'laskowski', 'loximuthal', 'mercator', 'miller', 'mollweide', 
    'mt flatpolar parabolic', 'mt flat polar quartic', 'mt flat polar sinusoidal', 
    'natural earth', 'natural earth1', 'naturalearth2', 'nell hammer', 
    'nicolosi', 'orthographic','patterson', 'peirce quincuncial', 'polyconic',
    'rectangular polyconic', 'robinson', 'satellite', 'sinumollweide', 
    'sinusoidal', 'stereographic', 'times', 'transverse mercator', 
    'van der grinten', 'van dergrinten2', 'van der grinten3', 'van der grinten4',
    'wagner4', 'wagner6', 'wiechel', 'winkel tripel','winkel3'
])

attribution = (
    'Data source: ' + 
    '[Workout Wednesday](https://workout-wednesday.com/pbi-2025-w43/)'
)
#----- LOAD AND CLEAN THE DATASET ----------------------------------------------
df = (
    pl.read_csv('Sales.csv')
    .select(
        DATE = pl.col('Order Date')
            .str.split(' ')
            .list.first()
            .str.to_date(format='%m/%d/%Y'),
        SALES = pl.col('Sales').cast(pl.Float32),
        COUNTRY = pl.col('Country'),
        SEGMENT = pl.col('Segment'),
    )
    .with_columns(
        COUNTRY = pl.col('COUNTRY')
                    .str.replace('Russia', 'Russian Federation')
    )
    .filter(pl.col('COUNTRY') != 'Bahrain') # Not enough data to include Bahrain
)

#----- CREATE GLOBAL LISTS -----------------------------------------------------
countries = (sorted(df.unique('COUNTRY').get_column('COUNTRY').to_list()))
iso_codes = [pycountry.countries.lookup(c).alpha_3 for c in countries]
segments = (sorted(df.unique('SEGMENT').get_column('SEGMENT').to_list()))

dict_country_color = dict(
    zip(
        countries, [px.colors.qualitative.Alphabet[i] for i in range(len(countries)) ]
    )
)
#----- Make Dataframe of ISO-3 CODES by country, then join with df -------------
df_iso = (   # join this with main df to get ISO-3 codes for each country
    pl.DataFrame({
        'COUNTRY': countries,
        'ISO-3': iso_codes
    })
)
df = (
    df
    .join(df_iso, on='COUNTRY', how='left')
)

#----- FUNCTIONS ---------------------------------------------------------------
def set_timeline_axis(fig):
    fig.update_xaxes(
        dtick='M6',
        tickformat='%b\n%Y',
        ticklabelmode="period",
        minor=dict(
            ticks="outside",
            tickwidth=2,
            ticklen=30,
            dtick="M12",
        ),
    )
    return fig

def get_tl_country(country, template):
    df_country_timeline = ( # group by dynamic to bin by month
        df
        .filter(pl.col('COUNTRY') == country)
        .group_by_dynamic('DATE', every='1mo').agg(pl.col('SALES').sum())
    )
    mean_sales = df_country_timeline['SALES'].mean()
    df_country_timeline = (
        df_country_timeline
        .with_columns(MEAN_SALES = pl.lit(mean_sales))
        .with_columns(
            FILL_COLOR = pl.when(pl.col('SALES')>pl.col('MEAN_SALES'))
            .then(pl.lit('green'))
            .otherwise(pl.lit('red'))
        )   
    )
    x = df_country_timeline['DATE'].to_numpy()
    y1 = df_country_timeline['SALES'].to_numpy()
    y2 = df_country_timeline['MEAN_SALES'].to_numpy()

    fig = go.Figure()

    fig.add_trace(go.Scatter(
        x=x, y=y1,
        mode='lines',
        line=dict(color='gray', width=1, shape='hv'),
        name='MONTHLY_SALES_TOT',
    ))

    fig.add_trace(go.Scatter(
        x=x, y=y2,
        mode='lines',
        line=dict(color='green', width=1, dash='dash'),
        name='MONTHLY_SALES_MEAN',
    ))

    fig.update_layout(
        template=template,
        hovermode='x unified',
        showlegend=False,
        title_text = f'Total Sales Timeline: <b>{country}</b>',
        margin=dict(l=50, r=100, t=50, b=20),
    )

    fill_color = df_country_timeline['FILL_COLOR'].to_list()
    for i in range(len(x) - 1):
        fig.add_trace(go.Scatter(  # define fill area using 4 defined points
            x=[x[i], x[i+1], x[i+1], x[i]],
            y=[y1[i], y1[i], y2[i+1], y2[i+1]],
            fill='toself',
            fillcolor=fill_color[i],
            line=dict(width=0),
            mode='lines',
            hoverinfo='skip',
            showlegend=False,
            opacity=0.5
        ))
    fig.add_annotation(
        x=1, xref='paper',
        y = mean_sales, yref='y', 
        text = f'{country}<br>Mean<br>${mean_sales:,.0f}',
        xanchor='left', xshift=10, showarrow=False,
        font=dict(color='gray', size=12)
    )
    fig = set_scatter_traces(fig)  # takes care of hover, line size, line shape
    fig = set_timeline_axis(fig)  # set x-axis timeline 6-month tick spacing
    return fig

def get_cum_tl_countries(countries, template):
    df_countries = ( # group by dynamic to bin by month
        df
        .filter(pl.col('COUNTRY').is_in(countries))
        .pivot(
            on='COUNTRY',
            index='DATE',
            values='SALES',
            aggregate_function='sum',
        )
        .group_by_dynamic('DATE', every='1mo').agg(pl.col(countries).sum())
        .with_columns([   # replace raw data with cumulative sums
            pl.col(c).cum_sum().alias(c) for c in countries
        ])
    )

    fig = go.Figure()    
    for country in countries:
        fig.add_trace(go.Scatter(
            name=country,
            x=df_countries['DATE'],
            y=df_countries[country],
            line=dict(color=dict_country_color[country]),
            )
        )

    fig.update_layout(
        template=template,
        title_text = 'Cumulative sales by <b>selected countries</b>', 
        hovermode='x unified',
        showlegend=True,
        margin=dict(l=50, r=100, t=50, b=20),
        xaxis = dict(title=''),
        yaxis = dict(title='Value [US $]'),
        legend=dict(
            title='<b>Country</b>', 
            yanchor='top', y=1, xanchor='left', x=1.1
            ),
    )
    fig = set_scatter_traces(fig)  # takes care of hover, line size, line shape
    fig = set_timeline_axis(fig)  # set x-axis timeline 6-month tick spacing
    return fig

def get_tl_country_breakdown(country, template):
    df_country_groupby = ( # group by dynamic to bin by month
        df
        .filter(pl.col('COUNTRY') == country)
        .pivot(
            on='SEGMENT',
            index='DATE',
            values='SALES',
            aggregate_function='sum',
        )
        .sort('DATE')
        .group_by_dynamic('DATE', every='1mo').agg(pl.col(segments).sum())
         .with_columns([   # replace raw data with cumulative sums
            pl.col(c).cum_sum().alias(c) for c in segments
        ])
    )

    fig = px.scatter(
        df_country_groupby,
        x='DATE',
        y=segments,
        title=f'Cumulative Sales by Market Segment: <b>{country}</b>',
        opacity=1.0,
        template=template,
    )       

    fig.update_layout(
        template=template,
        hovermode='x unified',
        showlegend=True,
        margin=dict(l=50, r=100, t=50, b=20),
        xaxis = dict(title=''),         
        yaxis = dict(title='Value [US $]'),
        legend=dict(
            title='<b>Market Segment</b>', 
            yanchor='top', y=1, xanchor='left', x=0.1
        )
    )
    fig = set_scatter_traces(fig)  # takes care of hover, line size, line shape
    fig = set_timeline_axis(fig)  # set x-axis timeline 6-month tick spacing
    return fig

def get_choropleth(countries, template, projection):
    df_choro = (
        df
        .filter(pl.col('COUNTRY').is_in(countries))
        .with_columns(
            SALES = pl.col('SALES').sum().over('COUNTRY')
        )
        .unique(['SALES', 'COUNTRY'])
        .sort(['COUNTRY'])
    )

    fig = px.choropleth(
        df_choro,
        locations='ISO-3', 
        locationmode='ISO-3',
        hover_name='SALES', # column to add to hover information
        template=template,
        title='World map of selected countries',
        subtitle = f'{projection} projection',
        projection=projection,
        custom_data=['COUNTRY', 'SALES'],
        color='COUNTRY',
        color_discrete_map=dict_country_color
    )
    fig.update_layout(
        showlegend=False,
        margin=dict(l=50, r=100, t=50, b=20),
    )
    fig.update_traces(  # for choropleth, setup hover to only show country
        hovertemplate=(
                "%{customdata[0]}<br>"
                "SALES: US$ %{customdata[1]:,.0f}<br>"
                "<extra></extra>"
            )
        )
    return fig

def set_scatter_traces(fig):
    fig.update_traces(mode='lines')
    for trace in fig.data:
        trace.line.width = 2      # adjust line thickness
        trace.line.shape = 'hv'    # 'hv' for step-like appearance
        fig.for_each_trace(
        lambda t: t.update(
            hovertemplate=(
                f"<b>{t.name}</b><br>"
                'US$ %{y:,.0f}'
                "<extra></extra>"      # removes the trace name footer
            )
        )
    )
    return fig

#----- DASH COMPONENTS------ ---------------------------------------------------
dmc_select_country = (
    dmc.Select(
        label='Pick one country',
        id='pick-country',
        data= countries,
        value='Canada',     # default is arbitrary
        searchable=False,   # Enables search functionality
        clearable=True,     # Allows clearing the selection
        size='sm',
    ),
)

dmc_select_countries = (
    dmc.MultiSelect(
        label='Pick one or more countries',
        placeholder='Pick one or more countries',
        id='pick-countries',
        data= countries,
        value=[countries[0], countries[1]], # default countries arbitrary
        clearable=False,    # Allows clearing the selection
        size='sm',
        hidePickedOptions=True
    ),
)

dmc_select_template = (
    dmc.Select(
        label='Pick your favorite Plotly template',
        id='template',
        data= template_list,
        value=template_list[2],
        searchable=False,  # Enables search functionality
        clearable=True,    # Allows clearing the selection
        size='sm',
    ),
)
dmc_select_projection = (
    dmc.Select(
        label='Pick one projection for the choropleth map',
        id='choro_projection',
        data= choro_projections,
        value='hufnagel',               # arbitrary choice
        searchable=False,               # Enables search functionality
        clearable=True,                 # Allows clearing the selection
        size='sm',
    ),
)
#----- DASH APPLICATION --------------------------------------------------------
app = Dash()
server = app.server
app.layout =  dmc.MantineProvider([
    html.Hr(style=style_horizontal_thick_line),
    dmc.Text('International Sales', ta='center', style=style_h2),
    dmc.Text(attribution, ta='center', style=style_h3),
    html.Hr(style=style_horizontal_thick_line), 
        dmc.Grid(children = [
        dmc.GridCol(dmc_select_template, span=2, offset = 1),
    ]),  
    dmc.Grid(children = [
        dmc.GridCol(dmc_select_country, span=2, offset = 1),
        dmc.GridCol(dmc_select_countries, span=3, offset = 4),
    ]),  
    dmc.Space(h=10),
    dmc.Grid(children = [
        dmc.GridCol(dcc.Graph(id='tl_country'), span=6, offset=0),  
        dmc.GridCol(dcc.Graph(id='tl_cum_countries'), span=6, offset=0), 
    ]),
    dmc.Grid(children = [
        dmc.GridCol(dmc_select_projection, span=3, offset = 7),
    ]), 
    dmc.Grid(children = [
        dmc.GridCol(dcc.Graph(id='tl_groupby'), span=6, offset=0), 
        dmc.GridCol(dcc.Graph(id='choropleth'), span=6, offset=0),           
    ]),
])
@app.callback(
    Output('tl_country', 'figure'),
    Output('tl_cum_countries', 'figure'),
    Output('tl_groupby', 'figure'),
    Output('choropleth', 'figure'),
    Input('pick-country', 'value'),
    Input('pick-countries', 'value'),
    Input('template', 'value'),
    Input('choro_projection', 'value')
)
def callback(country, countries, template, choro_projection):
    if not isinstance(countries, list):  # if value is not a list, make it one
        countries = [countries]  
    tl_country  = get_tl_country(country, template)
    cum_tl_countries = get_cum_tl_countries(countries, template)
    tl_country_breakdown = get_tl_country_breakdown(country, template)
    choropleth = get_choropleth(countries, template, choro_projection)

    return tl_country, cum_tl_countries,tl_country_breakdown, choropleth
if __name__ == '__main__':
    app.run(debug=True)

Here is a screenshot:

4 Likes

Really cool dashboard @Mike_Purtell! I like it, it’s very clean. I just have one question/doubt, just to learn: what is this tool, dash._dash_renderer._set_react_version('18.2.0'), used for?

1 Like

Thank you @Avacsiglo21 .
Here is an explanation from Chat GPT:

dash._dash_renderer._set_react_version(‘18.2.0’)

This command comes from Plotly Dash, a Python framework for building web applications with interactive dashboards. Dash uses React.js under the hood to render components in the browser.

  • dash._dash_renderer This is an internal module in Dash that handles how the Python backend communicates with the React frontend. It’s not part of the public API, so normally you wouldn’t call it directly.
  • _set_react_version(‘18.2.0’) This function explicitly tells Dash which version of React to use when rendering components. In this case, it’s setting React to version 18.2.0.

:warning: Important Notes

  • This is not a standard user-facing command. It’s more of an internal hook used by Dash developers or advanced users who need to override the React version for compatibility reasons.
  • Dash typically manages React versions automatically. Manually setting it can help if:
    • You’re debugging mismatches between Dash components and React.
    • You’re testing new React features or ensuring compatibility with custom components.
2 Likes

Thanks you @Mike_Purtell ,

1 Like

@AnnMarieW , out of curiosity, do you think this necessary in Mike’s app?

@Mike_Purtell this is a cool unique way to visualize sales compared to mean. Nice job. How did you get that idea? Did you build a chart like that before?

2 Likes

This is only required if you are running DMC which requires React 18 with Dash 2 that uses React 16.
If you are using Dash >=3, this line can be deleted.

So ChatGPT is mostly right on this one except it didn’t know the detailed reason why/when it would be needed in this app.

3 Likes

Hi @adamschroeder ,

The idea to visualize sales compared to mean value with green shading above and red below came to me while looking at stock-price graphs and other timelines. I saw code for how to do that with plotly a few years back but felt it was too difficult for me, more of a cludge or a hack so I gave up. Basically, it involves drawing shaded rectangles or trapezoids under the curve, with 0-width borders. But I picked up on this idea a few weeks back and no longer consider it too difficult for me to use and had a great experience. I couldn’t wait for a chance to show this off to Figure Friday.

A few things I learned are:

Rectangular shapes go with the stepped line shape I used. When line shape is ‘linear’, it is best to use trapezoids. Brings back memories from calculus using trapezoids to approximate the area under a curve.

I would only use this for single trace graphs.

Thank you for the question and comments. Have a great Thanksgiving.

1 Like

Thank you @AnnMarieW for this clarification. I didn’t really know what that line does, but happy I can remove it with Dash >=3, will give that a try.

1 Like

BTW @AnnMarieW and @Avacsiglo21 : I recently upgraded to Dash 3.3, so I removed the dash._dash_renderer statement as suggested, and all works fine. Conversation with both of you has improved my code. Thank you both.

2 Likes

Well Mike, it was @AnnMarieW who answered and clarified things for us :winking_face_with_tongue: :flexed_biceps: :flexed_biceps:

1 Like

Hello to the Figure Friday Friend Community,

This week, the dataset is about Sales from a Global Store nothing more appropriate for the occasion! Initially,
I created some clusters to identify/group similar customers and try to discover purchasing patterns.
This resulted in 4 clusters with a silhouette score of 0.45. Not the best number, but good enough for this case. Later, I gave up; it seemed a bit boring and didn’t reveal many patterns.
The idea that truly clicked with me was granularity. Given the amount of customers and products in the dataset,
let’s look for similar customers in a more granular way, not with clusters that group, but in this case,
by using direct statistical association rules (Market Basket Analysis). That’s where the application, Global Store/Sales Sniper, came from.
This application analyzes the purchase patterns of each customer we select using the dropdown menus: first we choose the segment, and then another specific filter within that segment for the client we want to evaluate.

KEY FEATURES

  • Recommendation Engine by Anchor Product: The system automatically identifies the main purchase category of each customer (their Anchor) and suggests complementary products with the highest probability of sale. It answers the question: Given that they bought X, what is the probability that they will buy Y?
  • Dynamic Opportunity Prioritization: It classifies recommendations into priority levels (High, Medium, Low) based on the unique statistical association strength of each customer, allowing the salesperson to focus their efforts where the return is most likely.
  • Market Gap Analysis: It compares the current customer’s spending against the Segment Benchmark. It graphically visualizes the money on the table,identifying categories where the customer is spending significantly less than the average of their peers.
  • Value Quantification: It calculates the potential monetary impact (Total Opportunity) if the suggested cross-sales are achieved, based on the average ticket price of the missing products.

It is important to clarify what the application is:

  1. A market intelligence tool based on proven historical behavior.
  2. A pattern discovery system for designing cross-sell strategies.
  3. An adaptive customer segmentation analysis platform.

What it is not:

  1. A Real-time Prediction System; this application does not use ML.
  2. A Recommendation Engine for E-commerce.
  3. A Customer Retention Tool.

Here the webapp link:

some images:



Any doubt/question/suggestion more than welcome

4 Likes

@Avacsiglo21 the confidence table for correlated purchases is powerful.

Why do say that this app is not a Recommendation Engine for E-commerce. Couldn’t it be?

1 Like

HI Adam, this is an excelent question as usual,
It is not because it focuses on simple correlation (association rules) based only on transactions, while a complete engine uses advanced Machine Learning algorithms that incorporate profile data (demographics) and behavior to perform individualized predictions about purchasing propensity.

The app vs Full Ecommerce Engine : Association Rules (MBA) vs Machine Learning (ML) and Collaborative Filtering.

Focus: Correlation (A \rightarrow B) vs Prediction of individual propensity.

Data: Only products and transactions vs Transactions + Customer Profile (Demographics, Web History).

That´s why I make some clarifications, are you agree or do you think differently, just curiosity?

2 Likes

I see, yes, that make sense to me, @Avacsiglo21

2 Likes

Hello @Avacsiglo21, excellent dash board and great explanation to @adamschroeder 's question. Thank you.

2 Likes

Hi Mike Imagine thanks you

1 Like

This Plotly dashboard provides a comprehensive overview of global sales performance, profitability, and market segmentation. The data covers 27 countries and 3 customer segments from 2017 to 2020.

Tabs are available for deeper insights, including:

  • Seles over time: Stacked bar chart
  • Profit vs Sales: Scatter plot
  • Top Countries Sales: Horizontal barchart
  • Sales by Segment: Donut chart
  • Profit by Country Map: Bubble map
  • Sales Metrics: Data table in AG-grid

6 Likes