# Plotly's Figure Friday challenge. See more info here: https://community.plotly.com/t/figure-friday-2024-week-52/89593
import pandas as pd
from dash import Dash, dcc, Input, Output, State, callback, _dash_renderer, ctx
import dash_mantine_components as dmc
import dash_ag_grid as dag
import plotly.express as px
_dash_renderer._set_react_version("18.2.0")
dmc.add_figure_templates(default="mantine_dark")
app = Dash(external_stylesheets=dmc.styles.ALL)
about = dcc.Markdown("""
This app was last updated in December 2024 and uses data from [PublicSaaSCompanies.com](https://publicsaascompanies.com/), a free database of 170+ SaaS businesses listed on the NYSE and NASDAQ. For the latest data, visit their website.
About "Rule of 40%":
- Rule of 40 is a convenient way to determine if a SaaS company is balancing the growth rate and the cost of achieving that growth rate. Historically, companies with a Rule of 40 equal to or greater than 40% will experience high enterprise value multiples to revenue.
Created for Plotly's **Figure Friday Challenge**, this app showcases interactive data visualizations of SaaS companies. Explore the dataset and join the discussion on the [Plotly Community Forum](https://community.plotly.com/t/figure-friday-2024-week-52/89593).
Built with **Dash Mantine Components** and **Dash AG Grid**, this Dash app is written in just ~200 lines of Python code. View the source on [GitHub](https://gist.github.com/AnnMarieW/6934d9fa27f9dc65b74c7e77b4fd0b8a)
""", style={"width": 450})
df = pd.read_csv('https://raw.githubusercontent.com/plotly/Figure-Friday/main/2024/week-52/SaaS-businesses-NYSE-NASDAQ.csv')
df1 = df.copy() # use the copy for cleaned data
# Function to clean a single value
def clean_value(value):
if isinstance(value, str):
value = value.replace('$', '').replace('%', '').replace(',', '')
return pd.to_numeric(value, errors='coerce')
# Remove $ and % formating from numeric columns so sort and filters work correctly in the grid
for column in df.columns:
if df1[column].apply(lambda x: isinstance(x, str) and (x.startswith('$') or x.endswith('%'))).any():
df1[column] = df1[column].apply(clean_value)
# add rule of 40 https://www.saasmetricsboard.com/rule-of-40
df1["Rule of 40%"] = (df1["YoY Growth%"] + df1["EBITDA Margin"]).round(2)
df["Rule of 40%"] = df1["Rule of 40%"].apply(lambda x: f"{x:.2f}%")
graph_columns = ['Company', 'Market Cap', 'Annualized Revenue', 'YoY Growth%', 'Revenue Multiple',
'Annualized EBITDA', 'YoY Change in EBITDA%', 'EBITDA Margin', 'Rule of 40%', 'Annualized Net Income',
'YoY Change in Net Income%', 'Net Income Margin', 'Cash and Short Term Investments',
'YoY Change in Cash and Short Term Investments%', 'Employee Count (estimate)',
'Annual Revenue per Employee (estimate)', 'Stock Price']
ag_grid = dag.AgGrid(
id="grid",
rowData=df1.to_dict("records"),
columnDefs=[{"field": "Company", "pinned": True}, {"field": "Last Quarter Filed"}] +
[{"field": i, 'type': 'rightAligned'} for i in graph_columns if i != "Company"],
columnSize="autoSize",
defaultColDef={"filter": True},
dashGridOptions={"animateRows": False, "rowSelection":'single'},
className="ag-theme-alpine-dark"
)
control_panel = dmc.Card([
dmc.Select(
label="Select Y Axis",
data=graph_columns,
value="Market Cap",
id="y"
),
dmc.Select(
label="Select X Axis",
data=graph_columns,
value="Company",
id="x",
mt="lg"
),
dmc.Text("Click column header to sort", mt="md", size="sm")
], w=160)
header = dmc.Group([
dmc.Title("Public SaaS Explorer", c="blue"),
dmc.HoverCard(
shadow="lg",
children=[
dmc.HoverCardTarget(dmc.Button("About",variant="outline")),
dmc.HoverCardDropdown(about),
],
)
], justify="space-between")
layout = dmc.Stack([
header,
ag_grid,
dmc.Group([control_panel, dmc.Card(dcc.Graph(id="graph" ), mt=0, withBorder=True, style={"flex":1})]),
dmc.Box(id="details"),
], p="lg")
app.layout = dmc.MantineProvider(layout, forceColorScheme="dark")
@callback(
Output("graph", "figure"),
Input("grid", "virtualRowData"),
Input("x", "value"),
Input("y", "value"),
State("grid", "columnState")
)
def update_figure(data, x, y, columnState):
if not data:
return {}
dff = pd.DataFrame(data)
sorted_by = next((c["colId"] for c in columnState if c.get("sort")), "Company") if columnState else "Company"
title = f"{y} by {x} (Sorted by: {sorted_by})" if x == "Company" else f"{y} vs {x}"
return px.scatter(dff, x=x, y=y, title=title, hover_data=[y, x, sorted_by, "Rule of 40%"], hover_name="Company")
@callback(
Output("grid", "columnDefs"),
Input("x", "value"),
Input("y", "value"),
State("grid", "columnDefs")
)
def update_grid_style(x,y, columnDefs):
for c in columnDefs:
if c["field"] in [x,y]:
c["cellStyle"] = {'background-color': 'var(--ag-selected-row-background-color)'}
else:
c["cellStyle"] = {'background-color': 'unset'}
return columnDefs
def StatsGrid(data): # creates the stats cards
stats = []
for stat in data:
stats.append(
dmc.Paper(
[
dmc.Group(
[dmc.Text(stat['title'], c="dimmed", tt="uppercase", fw=700)],
justify="space-between"
),
dmc.Group([dmc.Text(stat['value'], size="lg", fw=700)], gap="xs"),
dmc.Group(
[
dmc.Text(f"{stat['diff']}%", size="xs"),
dmc.Text("YoY change", size="xs", c="dimmed")
],
c="teal" if stat['diff'] > 0 else "red",
fw=500, gap=1,
)
],
withBorder=True,
p="xs"
)
)
return dmc.SimpleGrid(children=stats, cols={"base": 1, "xs": 2, "md": 4})
@callback(
Output("details", "children"),
Output("grid", "selectedRows"),
Input("graph", "clickData"),
Input("grid", "selectedRows"),
prevent_initial_call=True,
)
def update_detail_cards(clickData, selectedRows):
if ctx.triggered_id == "graph":
if not clickData:
return "", selectedRows
company=clickData["points"][0]["hovertext"]
selectedRows= df1[df1["Company"] == company].to_dict("records")
if ctx.triggered_id == "grid":
if not selectedRows:
return "", selectedRows
company=selectedRows[0]["Company"]
dff = df[df["Company"] == company].iloc[0] #formated
dff1= df1[df1["Company"] == company].iloc[0] #numeric
data = [
{'title': 'Revenue', 'value': dff["Annualized Revenue"], 'diff': dff1["YoY Growth%"]},
{'title': 'EBITDA', 'value': dff["Annualized EBITDA"], 'diff': dff1["YoY Change in EBITDA%"]},
{'title': 'Net Income', 'value': dff["Annualized Net Income"], 'diff': dff1["YoY Change in Net Income%"]},
{'title': 'Cash', 'value': dff["Cash and Short Term Investments"], 'diff': dff1["YoY Change in Cash and Short Term Investments%"]},
]
selected_company_stats = dmc.Box([
dmc.Title(dff["Company"], order=3),
dmc.Text(f"Ticker: {dff['Ticker']}"),
dmc.Text(f"Products: {dff['Product Description']}"),
dmc.Text(f"Headquarters: {dff['Headquarters']}"),
dmc.Text(f"Founded in {dff['Year Founded']}, IPO in {dff['IPO Year']}"),
dmc.Anchor("Website", href=dff['Company Website']),
dmc.Anchor("2023 10-K Filing", href=dff['2023 10-K Filing'], pl="lg"),
])
return dmc.Card([
dmc.Group([
dmc.Title(f"Selected company: {dff['Company']}", order=3),
dmc.HoverCard([
dmc.HoverCardTarget(dmc.ActionIcon("?")),
dmc.HoverCardDropdown(selected_company_stats),
])
], justify="center"),
StatsGrid(data)
], withBorder=True), selectedRows
if __name__ == "__main__":
app.run(debug=True)