Figure Friday 2024 - week 28

Hey Everyone,
We’re excited to announce the first data set of the Figure Friday initiative. Every Friday Plotly will release a data set and a sample figure. The community will have one week to enhance that figure, build your own Plotly figure or create a Dash app.

This week’s data set comes from Workout Wednesday and it’s a sample superstore’s sales and profit.

Code for sample figure:
import pandas as pd
import plotly.express as px

# Load the data from the Excel file
df = pd.read_excel('Sample - Superstore.xls')

# Filter the data to only include sales <= 10,000
df_filtered = df[df['Sales'] <= 5000]

# Create a scatter plot with sales on the x-axis, profits on the y-axis, and color by category
fig = px.scatter(df_filtered, x='Sales', y='Profit', color='Category', title='Sales vs Profit by Category (Sales <= 10,000)')

# Show the plot
fig.show()

To participate in this initiative:

  • Create - use the weekly data set to build your own Plotly visualization or Dash app. Or, enhance the sample figure provided.
  • 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 (first one happening today at 12PM EDT!) to showcase your creation, receive feedback from the community, and vote on your favorite visualization.

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

Thank you to Workout Wednesday for creating and sharing the data set and state abbreviation sheet.

2 Likes

Excited to join Plotly’s first data visualization community challenge using Vizro, Plotly and PyCafe:v:

What’s the main issue with the initial chart?
The initial scatter chart was a good choice for showing the relationship between two numeric variables. However, it suffered from a common problem: overplotting.

Overplotting happens when data points overlap so much that it’s difficult to see relationships between them. In the current chart, it’s hard to determine how densely packed the data points are in certain areas.

Here are two ways to address this:

  1. Reduce the number of observation points: I filtered by one category/sub-category combination due to the number of products. This makes it easier for readers to identify the best and worst performing products in each group.
  2. Add opacity to your markers: Adding transparency to the markers helps make overlaps more visible.

I’ve added some other enhancements as well - take a look at the app in the links below! :slight_smile:

figure-friday

Sources:

8 Likes

Awesome app, @li.nguyen . Thanks for sharing and also thank you for explaining how you improved the sample visualization.

1 Like

I’ve create a simple dashboard by Plotly and Streamlit.

This dashboard helps users to explore which year and which state gives the most or least profit for a specific sub-category.

Dashboard is here :chart_with_upwards_trend:Figure Friday 2024, Week 28

5 Likes

In the original Workout Wednesday challenge, I liked how the figure showed the gross margin percent by state and product category. Inspired by this, I made a Dash app using Dash Bootstrap Components to present similar information. Each state’s data is displayed in a card rather than a single figure with subplots. This responsive design automatically adjusts to various screen sizes.

This dashboard would be useful for a manager monitoring profitability. It makes it easy to benchmark states and product categories within regions. I also added a Dash AG Grid, allowing users to drill down and find details of individual orders driving low or high gross margin results.

See the live app here: PyCafe - Dash - Superstore Gross Margin Analysis

See the code on Github

6 Likes

Wow, these entries are amazing!

@chenyulue - I really like your clean design and the quick summary sentences or instructions you added for each chart title/sub-title. This makes it super easy to use and understand what to analyze. I also love the sorted bars; they make it very easy to identify the best and worst performers. Great job! :clap:

@AnnMarieW - Your executive regional overview is fantastic! :rocket: I love how you defined the user group and the focus of the analysis. The hierarchical filters are great—selecting a region filters the available states. The cards for each state are a smart visual choice; no scrolling needed, making it easy to compare everything. I also appreciate your consistent choice of colors throughout the entire dashboard.

1 Like

Hi all,

Really nice entries, I’m learning a lot with the possibilities we can do with Dash and Plotly.
Here is my contribution. It is a Plotly figure showing the Profit from 2021 and 2024.

Which sub-category developed the most and which ones are causing a loss in profit.

Had a bunch of support from the forum and ChatGPT, since I’m still learning =)

Edit: Adding the code here
Cheers!

5 Likes

Interesting figure @DenysC . Really highlights how much of an outlier the Copiers are.
Can you also share your code with us? Do you have it on GitHub?

1 Like

@AnnMarieW
I’m having so much fun playing around with your app :star_struck:
You’ve made it easy to filter and explore the data with just a few clicks. And thank you for sharing the code; it’s hard to believe that all this is under 300 lines of code. I bet you had some fun creating it :smile:

2 Likes

Good job, @chenyulue
Thanks for sharing the link to the app. Are you able to make the hover tooltip of the choropleth map darker so it’s easier to read its content?

I edited to add the link to the code!
Thanks!

1 Like

https://dash.geomapindex.com/figure_friday_week_1

Mainly focused on building an interactive map with this project. Created a search for customer names, state and category type. Setup flyto for clicking data in the ag Grid, setup a custom cluster to show category distribution, created 4 geojson’s to show the Regions and setup borders for each state, tooltip for marker data.

Code:

from dash import *
import dash_ag_grid as dag
import dash_leaflet as dl
import pandas as pd
import geopandas as gpd
import dash_mantine_components as dmc
import json
from dash_extensions.javascript import arrow_function, assign
#########################################
# Setup data and format for the map data, borders & cluster
#########################################

# Define the file path for data
file_path = '2024/week-28/Superstore_with_LAT_LNG.xlsx'

# Read the Excel file
df = pd.read_excel(file_path)

# Get unique State/Province values
unique_states = df['State/Province'].unique()

# Create data for Select component
state_select_data = [{"label": "Everything", "value": "Everything"}] + [
    {"label": state, "value": state} for state in sorted(unique_states)
]

# Create a dictionary mapping state/province to region
state_to_region = pd.Series(df.Region.values, index=df['State/Province']).to_dict()

# Convert any Timestamp columns to strings
for column in df.select_dtypes(include=['datetime', 'datetime64[ns]']):
    df[column] = df[column].astype(str)

# Convert the DataFrame to a GeoDataFrame
gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df.LNG, df.LAT)
)

# Convert the GeoDataFrame to GeoJSON
features = []
for _, row in df.iterrows():
    features.append({
        "type": "Feature",
        "geometry": {
            "type": "Point",
            "coordinates": [row["LNG"], row["LAT"]],
        },
        "properties": {
            "tooltip": f"Customer Name: {row['Customer Name']}<br>Segment: {row['Segment']}<br>Country/Region: {row['Country/Region']}<br>City: {row['City']}<br>State/Province: {row['State/Province']}<br>Postal Code: {row['Postal Code']}<br>Region: {row['Region']}<br>Product ID: {row['Product ID']}<br>Category: {row['Category']}<br>Sub-Category: {row['Sub-Category']}<br>Product Name: {row['Product Name']}<br>Sales: {row['Sales']}<br>Quantity: {row['Quantity']}<br>Discount: {row['Discount']}<br>Profit: {row['Profit']}",
            "Category": row['Category'],
        }
    })

geojson_dict = {
    "type": "FeatureCollection",
    "features": features
}

# Load GeoJSON data setup borders for regions
with open('2024/week-28/path_to_regions_geojson_file.geojson', 'r') as f:
    regions_geojson = json.load(f)

# Function to style the GeoJSON features based on the 'Region' property
# Define color mapping for regions
region_colors = {
    'East': '#ff0000',
    'West': '#00ff00',
    'Central': '#0000ff',
    'South': '#ffff00',
    'Unknown': '#808080'  # Grey for unknown regions
}

# Create separate GeoJSON data for each region
region_geojsons = {region: {"type": "FeatureCollection", "features": []} for region in region_colors.keys()}

# Apply the style to each feature in the GeoJSON
for feature in regions_geojson['features']:
    state_province = feature['properties']['name']
    region = state_to_region.get(state_province, 'Unknown')
    feature['properties']['fillColor'] = region_colors.get(region, '#808080')
    region_geojsons[region]['features'].append(feature)

# Define color mapping for categories
category_colors = {
    'Office Supplies': 'white',
    'Technology': 'black',
    'Furniture': 'purple',
    'Unknown': 'gray'  # Color for unknown categories
}

# JavaScript function to create custom clusters
cluster_to_layer = assign(
    """function(feature, latlng, index, context) {
    function ringSVG(opt) {
        function describeArc(opt) {
            const innerStart = polarToCartesian(opt.x, opt.y, opt.radius, opt.endAngle);
            const innerEnd = polarToCartesian(opt.x, opt.y, opt.radius, opt.startAngle);
            const outerStart = polarToCartesian(opt.x, opt.y, opt.radius + opt.ringThickness, opt.endAngle);
            const outerEnd = polarToCartesian(opt.x, opt.y, opt.radius + opt.ringThickness, opt.startAngle);
            const largeArcFlag = opt.endAngle - opt.startAngle <= 180 ? "0" : "1";
            return [ "M", outerStart.x, outerStart.y,
                     "A", opt.radius + opt.ringThickness, opt.radius + opt.ringThickness, 0, largeArcFlag, 0, outerEnd.x, outerEnd.y,
                     "L", innerEnd.x, innerEnd.y,
                     "A", opt.radius, opt.radius, 0, largeArcFlag, 1, innerStart.x, innerStart.y,
                     "L", outerStart.x, outerStart.y, "Z"].join(" ");
        }

        const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
            return { x: centerX + (radius * Math.cos((angleInDegrees - 90) * Math.PI / 180.0)),
                     y: centerY + (radius * Math.sin((angleInDegrees - 90) * Math.PI / 180.0)) };
        }

        opt = opt || {};
        const defaults = { width: 60, height: 60, radius: 20, gapDeg: 5, fontSize: 17, text: `test`,
                           ringThickness: 7, colors: [] };
        opt = {...defaults, ...opt};

        let startAngle = 90;
        let paths = '';
        const totalPerc = opt.colors.reduce((acc, val) => acc + val.perc, 0);
        for (let i = 0; i < opt.colors.length; i++) {
            const segmentPerc = opt.colors[i].perc / totalPerc;
            const endAngle = startAngle + (segmentPerc * 360) - opt.gapDeg;
            const d = describeArc({ x: opt.width / 2, y: opt.height / 2, radius: opt.radius, ringThickness: opt.ringThickness, startAngle, endAngle });
            paths += `<path fill="${opt.colors[i].color}" d="${d}"></path>`;
            startAngle = endAngle + opt.gapDeg;
        }

        return `<svg width="${opt.width}" height="${opt.height}">
                    ${paths}
                    <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle" font-size="${opt.fontSize}"
                          fill="black">${opt.text || opt.goodPerc}
                    </text>
                </svg>`;
    }

    const leaves = index.getLeaves(feature.properties.cluster_id);
    const categories = leaves.map(leaf => leaf.properties.Category);

    // Count the occurrences of each category
    const category_counts = categories.reduce((acc, category) => {
        acc[category] = (acc[category] || 0) + 1;
        return acc;
    }, {});

    // Calculate the percentage for each category
    const colors = Object.keys(category_counts).map(category => ({
        color: context.hideout.category_colors[category] || 'gray',
        perc: category_counts[category]
    }));

    const scatterIcon = L.DivIcon.extend({
        createIcon: function(oldIcon) {
               let icon = L.DivIcon.prototype.createIcon.call(this, oldIcon);
               return icon;
        }
    });

    const total = feature.properties.point_count_abbreviated;

    const icon = new scatterIcon({
        html: ringSVG({
                text: `${total}`,
                colors
            }),
        className: "marker-cluster",
        iconSize: L.point(60, 60)
    });
    return L.marker(latlng, {icon: icon});
}
"""
)
#########################################
# Setup Layout
#########################################
dont_show = dmc.Stack(
            [
                dmc.TextInput(
                    placeholder="Filter Locations...",
                    style={"width": '95%', "margin-bottom": "9px"},
                    id="superstore_quick_filter_input",
                ),
                dmc.Group(
                    [
                        dmc.Select(
                            # label='Select a State',
                            placeholder="Select a State / Provence",
                            id="superstore_autocomplete_r_map",
                            value="Everything",
                            data=state_select_data,
                            style={
                                "width": '45%',
                                "marginBottom": 10,
                                "color": "red",
                            },
                        ),
                        dmc.Select(
                            placeholder="Category",
                            id="superstore_location_type",
                            value="everything",
                            data=[
                                {"label": "Everything", "value": "everything"},
                                {"label": "Office Supplies", "value": "Office Supplies"},
                                {"label": "Technology", "value": "Technology"},
                                {"label": "Furniture", "value": "Furniture"},
                            ],
                            style={
                                "width": '45%',
                                "marginBottom": 10,
                                "color": "red",
                            },
                        ),

                    ],
                    grow=False,
                    position="left",
                ),
                dag.AgGrid(
                    className="ag-theme-alpine-dark",
                    id="superstore_quick_filter_simple",
                    rowData=df.to_dict('records'),
                    columnDefs=[
            {"headerName": i, "field": i, "width": '100%'} for i in df.columns
        ] + [
            {
                "headerName": "Profit",
                "field": "Profit",
                "cellStyle": {
                    "styleConditions": [
                        {
                            "condition": "params.value > 0",
                            "style": {"backgroundColor": "rgba(0, 255, 0, 0.3)"}
                        },
                        {
                            "condition": "params.value < 0",
                            "style": {"backgroundColor": "rgba(255, 0, 0, 0.3)"}
                        },
                        {
                            "condition": "params.value == 0",
                            "style": {"backgroundColor": "rgba(255, 255, 255, 0.3)"}
                        }
                    ]
                },
                "width": '100%'
            }
        ],
                    defaultColDef={"filter": False, "editable": False},
                    dashGridOptions={
                        'suppressMenuHide': True,
                        "rowSelection": "single",
                        "animateRows": False,
                        "pagination": False,
                    },
                    style={},
                ),
            ], style={"display": "none"}
        )

# Initialize the Dash app
app = Dash(__name__, external_stylesheets=["https://use.fontawesome.com/releases/v6.2.1/css/all.css",])

# Create the layout with a map and a table
app.layout = html.Div([
    dont_show,
    html.Div(
                    id="superstore_search_display",
                    style={
                        "position": "absolute",
                        "left": "60px",
                        "top": "8vh",
                        "zIndex": "1001",
                    },
                ),
    dmc.Grid(
        children=[
            dmc.Col(dl.Map(id="superstore_map", center=[39.8283, -98.5795], zoom=4, children=[
                dl.TileLayer(),
                dl.EasyButton(
                    icon="fa-search",
                    title="Search Map",
                    id="superstore_search_map_display_btn",
                    n_clicks=1,
                ),
                dl.GeoJSON(
                    data=geojson_dict,
                    id="superstore_locations_layer",
                    cluster=True,
                    zoomToBoundsOnClick=True,
                    superClusterOptions=dict(radius=40),
                    hideout=dict(
                        category_colors=category_colors,
                        circleOptions=dict(fillOpacity=1, stroke=False, radius=3),
                        min=0,
                    ),
                    clusterToLayer=cluster_to_layer,
                ),
                dl.GeoJSON(
                    data=region_geojsons['East'],
                    id="superstore_regions_layer_east",
                    hoverStyle=arrow_function(dict(weight=5, color='#777', dashArray='')),
                    style=dict(weight=2, fillColor=region_colors['East'], fillOpacity=0.7)
                ),
                dl.GeoJSON(
                    data=region_geojsons['West'],
                    id="superstore_regions_layer_west",
                    hoverStyle=arrow_function(dict(weight=5, color='#777', dashArray='')),
                    style=dict(weight=2, fillColor=region_colors['West'], fillOpacity=0.7)
                ),
                dl.GeoJSON(
                    data=region_geojsons['Central'],
                    id="superstore_regions_layer_central",
                    hoverStyle=arrow_function(dict(weight=5, color='#777', dashArray='')),
                    style=dict(weight=2, fillColor=region_colors['Central'], fillOpacity=0.7)
                ),
                dl.GeoJSON(
                    data=region_geojsons['South'],
                    id="superstore_regions_layer_south",
                    hoverStyle=arrow_function(dict(weight=5, color='#777', dashArray='')),
                    style=dict(weight=2, fillColor=region_colors['South'], fillOpacity=0.7)
                )
            ], style={'height': '100vh', 'width': '100%', 'margin': "auto", "display": "block"}), span=12),
            # dmc.Col(dcc.Graph(figure=fig_sankey, style={'height': '100vh', 'width': '100%', 'margin': "auto", "display": "block"}), span=6),
        ],
        gutter="xl",
    ),
])

#########################################
# Setup Callbacks
#########################################

@callback(
    Output("superstore_search_display", "children"),
    Input("superstore_search_map_display_btn", "n_clicks"),
)
def show_search_map(n_clicks):
    if n_clicks % 2 == 0:
        return dmc.Stack(
            [
                dmc.TextInput(
                    placeholder="Filter Locations...",
                    style={"width": '95%', "margin-bottom": "9px"},
                    id="superstore_quick_filter_input",
                ),
                dmc.Group(
                    [
                        dmc.Select(
                            # label='Select a State',
                            placeholder="Select a State / Provence",
                            id="superstore_autocomplete_r_map",
                            value="Everything",
                            data=state_select_data,
                            style={
                                "width": '45%',
                                "marginBottom": 10,
                                "color": "red",
                            },
                        ),
                        dmc.Select(
                            placeholder="Category",
                            id="superstore_location_type",
                            value="everything",
                            data=[
                                {"label": "Everything", "value": "everything"},
                                {"label": "Office Supplies", "value": "Office Supplies"},
                                {"label": "Technology", "value": "Technology"},
                                {"label": "Furniture", "value": "Furniture"},
                            ],
                            style={
                                "width": '45%',
                                "marginBottom": 10,
                                "color": "red",
                            },
                        ),

                    ],
                    grow=False,
                    position="left",
                ),
                dag.AgGrid(
                    className="ag-theme-alpine-dark",
                    id="superstore_quick_filter_simple",
                    rowData=df.to_dict('records'),
                    columnDefs=[
            {"headerName": i, "field": i, "width": '100%'} for i in df.columns
        ] + [
            {
                "headerName": "Profit",
                "field": "Profit",
                "cellStyle": {
                    "styleConditions": [
                        {
                            "condition": "params.value > 0",
                            "style": {"backgroundColor": "rgba(0, 255, 0, 0.3)"}
                        },
                        {
                            "condition": "params.value < 0",
                            "style": {"backgroundColor": "rgba(255, 0, 0, 0.3)"}
                        },
                        {
                            "condition": "params.value == 0",
                            "style": {"backgroundColor": "rgba(255, 255, 255, 0.3)"}
                        }
                    ]
                },
                "width": '100%'
            }
        ],
                    defaultColDef={"filter": False, "editable": False},
                    dashGridOptions={
                        'suppressMenuHide': True,
                        "rowSelection": "single",
                        "animateRows": False,
                        "pagination": False,
                    },
                    style={},
                ),
            ]
        )
    else:
        return []

@callback(
    Output("superstore_map", "viewport", allow_duplicate=True),
    Output("superstore_quick_filter_simple", "rowData", allow_duplicate=True),
    Input("superstore_autocomplete_r_map", "value"),
    prevent_initial_call=True,
)
def search_state_map(value):
    if value == "Everything":
        return dict(center=[39.8283, -98.5795], zoom=4, transition="flyTo"), df.to_dict("records")
    else:
        filtered_df = df[df['State/Province'] == value]
        if not filtered_df.empty:
            center_lat = filtered_df['LAT'].mean()
            center_lon = filtered_df['LNG'].mean()
            return dict(center=[center_lat, center_lon], zoom=6, transition="flyTo"), filtered_df.to_dict("records")
        else:
            return no_update, no_update

@callback(
    Output("superstore_quick_filter_simple", "dashGridOptions"),
    Output("superstore_locations_layer", "data", allow_duplicate=True),
    Output("superstore_quick_filter_simple", "rowData", allow_duplicate=True),
    Input("superstore_quick_filter_input", "value"),
    Input("superstore_location_type", "value"),
    Input('superstore_autocomplete_r_map', 'value'),
    prevent_initial_call=True,
)
def update_filter_and_locations_layer(filter_value, location_type, state):
    # Filter by state
    if state == "Everything":
        rowData = df
    else:
        rowData = df[df['State/Province'] == state]

    # Filter by category
    if location_type != "everything":
        rowData = rowData[rowData['Category'] == location_type]

    # Filter by name (assuming you want to filter by Customer Name)
    if filter_value:
        rowData = rowData[rowData['Customer Name'].str.contains(f'{filter_value}', case=False, na=False)]

    # Create GeoJSON features with tooltip
    features = []
    for _, row in rowData.iterrows():
        features.append({
            "type": "Feature",
            "geometry": {
                "type": "Point",
                "coordinates": [row["LNG"], row["LAT"]],
            },
            "properties": {
                "Category": row['Category'],
                "tooltip": f"Customer Name: {row['Customer Name']}<br>Segment: {row['Segment']}<br>Country/Region: {row['Country/Region']}<br>City: {row['City']}<br>State/Province: {row['State/Province']}<br>Postal Code: {row['Postal Code']}<br>Region: {row['Region']}<br>Product ID: {row['Product ID']}<br>Category: {row['Category']}<br>Sub-Category: {row['Sub-Category']}<br>Product Name: {row['Product Name']}<br>Sales: {row['Sales']}<br>Quantity: {row['Quantity']}<br>Discount: {row['Discount']}<br>Profit: {row['Profit']}"
            }
        })

    geojson_dict = {
        "type": "FeatureCollection",
        "features": features
    }

    # Update the quick filter
    newFilter = Patch()
    newFilter["quickFilterText"] = filter_value

    return newFilter, geojson_dict, rowData.to_dict("records")


@callback(
    Output("superstore_map", "viewport", allow_duplicate=True),
    Input("superstore_quick_filter_simple", "selectedRows"),
    prevent_initial_call=True,
)
def ag_grid_selection(selected_rows):
    if selected_rows:
        return dict(
            center=[selected_rows[0]['LAT'], selected_rows[0]['LNG']],
            zoom=10,
            transition="flyTo",
        )
    else:
        return no_update


# Run the app
if __name__ == '__main__':
    app.run_server(debug=True)
5 Likes

I’ve set the font color in all tooltips to black. An example of “Don’t stick to the default setting” in data vis :rofl:

Thanks for pointing it out!

3 Likes

This map is fantastic! I really appreciate how it initially shows the number of observations and then reveals the exact locations as we zoom in. It keeps everything organized and makes it really enjoyable to explore! :rocket: I’ll bookmark your code :smile: Would love to re-use this soon!

1 Like

Thanks for sharing the code, @PipInstallPython :pray:

Highlighting a few more submissions that we got through the Discord channel.

By @import.pandas:

By @movingking:

By @Mike:

By @Abhi:

By @Alexey:

2 Likes

If you are using social media, please don’t forget to post your visualizations to LinkedIn or Twitter with the hashtags #FigureFriday and #plotly.

Thanks for the kind words :slightly_smiling_face:, took a considerable amount of time to setup the custom cluster. Figure it was ~ 3 months of back and forth testing until I was able to get it working as intended, this was my first time taking it out of its initial project and pairing it with a different dataset. Which was a good exercise as with this .xlsx file containing +10k lines of code it was a good stress on the code to see if breakage could happen at a certain point with so much data.

The original file didn’t contain lat or lng, so I ended up looking up github repos for usa and Canadian zip codes as a way to reference the location at which the transactions took place. Was able to cross example most of the lines of the dataset with that and automatically create a lat and lon dataset. Had to manually look up ~200 Zip Codes that where not in either of the zip code repos for canada and the USA.

Big things to note in regards to creating the custom cluster is making sure to create that features section for each row in the df with the “properties” - “Category” as being what the the cluster is built around. Outside of that, I was having trouble using a single dl.GeoJSON for the superstore_regions_layer (borders) but found creating 4 a quicker and easier solution to implement for setting up the region borders of the dataset which where built on top of a geojson data file of all the states and provinces within north America, just associated the state column in 2024/week-28/path_to_regions_geojson_file.geojson with the state column in 2024/week-28/Superstore_with_LAT_LNG.xlsx and expanded out the .geojson dataset based on the region associated with the dataframe to setup the Region borders fillcolor.

3 Likes

This amazing work shows me what I am missing without a dash enterprise account. Hopefully some day after my cash-strapped start-up gets traction. Very impressive, well done.

Hi @Mike_Purtell

Thanks for your kind words :blush: Note that all the apps posted here for Figure Friday are made with open source Dash and Plotly Figures! (although Dash Enterprise adds a lot of other great features you will be interested in once your start-up gets traction :slight_smile: )

2 Likes