"""
Visualize salary data from theme/static/salary.csv with plotly.
Usage:
python utils/salary_visualization.py # open in a browser
python utils/salary_visualization.py light.webp # write an image (needs kaleido)
python utils/salary_visualization.py light.webp dark.webp # light and dark variants
"""
import sys
from datetime import UTC, datetime
import pandas as pd
import plotly.graph_objs as go
CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv"
# Surfaces match theme/static/styles.css.
THEMES = {
"light": {
"surface": "#ffffff",
"text": "#0b0b0b",
"muted": "#52514e",
"grid": "#f0efec",
"band": "#f8f8f6",
},
"dark": {
"surface": "#181a1b",
"text": "#ffffff",
"muted": "#c3c2b7",
"grid": "#262829",
"band": "#1f2122",
},
}
# Each employer's brand color. Walgreens is the uniform blue rather than the
# logo red; Nebraska is darkened to separate it from Ameritas.
BRAND = {
"Walgreens": "#5fb3e8",
"University of Nebraska": "#9b0000",
"Ameritas": "#d51e22",
"Nelnet": "#afd135",
"Ernst & Young": "#ffe600",
"KPMG": "#00338d",
}
PATTERN = {"salaried": "", "hourly": "/"}
BAR_HEIGHT = 1400 # in dollars, about 6px at the default figure size
HOURS_PER_YEAR = 2080
def load() -> pd.DataFrame:
df = pd.read_csv(CSV_PATH)
df["Start"] = pd.to_datetime(df["Start"])
df["End"] = pd.to_datetime(df["End"])
return df.sort_values(["Start", "End"]).reset_index(drop=True)
def label(row: pd.Series, muted: str) -> str:
salary = f"${row['Salary']:,.0f}"
detail = row["Title"]
if row["PayType"] == "hourly":
# A second "$" in one string makes plotly parse it as LaTeX.
detail = f"${row['Salary'] / HOURS_PER_YEAR:,.0f}/hr · " + detail
if row["PercentChange"]:
detail += f" · {row['PercentChange'] * 100:+.1f}%"
return f"{salary} {detail}"
def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
t = THEMES[theme]
fig = go.Figure()
companies = list(dict.fromkeys(df["Company"]))
colors = BRAND
# Thin connectors between a job's end and the next job's start, as shapes
# on the below layer so they never cover a bar's border. Concurrent jobs
# get none.
ends = {row["End"]: row for _, row in df.iterrows()}
for _, row in df.iterrows():
prev = ends.get(row["Start"])
if prev is None:
continue
fig.add_shape(
type="line",
x0=row["Start"],
x1=row["Start"],
y0=prev["Salary"],
y1=row["Salary"],
line={"color": t["grid"], "width": 1.5},
layer="below",
)
# One bar trace per company and pay type, drawn as horizontal bars so
# each segment gets a border. Hourly jobs are hatched. Legend entries are
# separate dummy traces so every company swatch is solid.
def marker(color: str, pay: str) -> dict:
return {
"color": color,
"line": {"color": t["text"], "width": 1},
"pattern": {
"shape": PATTERN[pay],
"fgcolor": t["surface"],
"bgcolor": color,
"size": 5,
"solidity": 0.4,
},
}
for (company, pay), rows in df.groupby(["Company", "PayType"]):
fig.add_trace(
go.Bar(
orientation="h",
base=rows["Start"],
x=(rows["End"] - rows["Start"]).dt.total_seconds() * 1000,
y=rows["Salary"],
width=BAR_HEIGHT,
marker=marker(colors[company], pay),
hoverinfo="skip",
showlegend=False,
)
)
entries = [(c, colors[c], "salaried") for c in companies]
entries += [(pay.capitalize(), t["muted"], pay) for pay in PATTERN]
for name, color, pay in entries:
fig.add_trace(go.Bar(x=[None], y=[None], name=name, marker=marker(color, pay)))
# Direct labels above each segment, starting at the segment's left end so
# they extend over the empty space under the next, higher step. The last
# segment anchors on its right end so the label stays inside the plot. A
# label moves below its segment when a later segment at a similar salary
# starts within the label's reach (the concurrent 2017 jobs).
y_gap = df["Salary"].max() * 0.035
last = len(df) - 1
for i, row in df.iterrows():
later = df.iloc[i + 1 :]
above = not (
((later["Start"] - row["Start"]).dt.days < 400)
& ((later["Salary"] - row["Salary"]).abs() < y_gap)
).any()
fig.add_annotation(
x=row["End"] if i == last else row["Start"],
xanchor="right" if i == last else "left",
y=row["Salary"],
yanchor="bottom" if above else "top",
yshift=6 if above else -6,
text=label(row, t["muted"]),
showarrow=False,
font={"color": t["text"], "size": 14},
)
# Shade everything after today so the current job's end reads as projected.
today = datetime.now(tz=UTC).date()
fig.add_vrect(
x0=today,
x1=df["End"].max(),
fillcolor=t["band"],
line_width=0,
layer="below",
)
fig.add_annotation(
x=today,
y=0,
yref="paper",
text="today",
showarrow=False,
xanchor="left",
xshift=4,
yanchor="bottom",
yshift=4,
font={"color": t["muted"], "size": 12},
)
fig.add_annotation(
x=0,
y=0,
yshift=-105,
xref="paper",
yref="paper",
text=(
f"Hourly rates annualized at {HOURS_PER_YEAR:,} hours. "
"Percent change is against the preceding job. "
"Shaded area is after today."
),
showarrow=False,
xanchor="left",
yanchor="top",
font={"color": t["muted"], "size": 12},
)
pad = pd.Timedelta(days=180)
fig.update_layout(
title={
"text": f"Annualized pay by job, {df['Start'].min().year}–{df['End'].max().year}",
"x": 0.03,
},
template="plotly_white",
barmode="overlay",
paper_bgcolor=t["surface"],
plot_bgcolor=t["surface"],
font={"family": "monospace", "size": 14, "color": t["text"]},
margin={"l": 90, "r": 40, "t": 70, "b": 140},
width=1600,
height=1000,
xaxis={
"type": "date",
"showgrid": True,
"gridcolor": t["grid"],
"dtick": "M12",
"tickformat": "%Y",
"linecolor": t["muted"],
"range": [df["Start"].min() - pad, df["End"].max() + pad],
},
yaxis={
"showgrid": True,
"gridcolor": t["grid"],
"tickformat": "$,.0f",
"rangemode": "tozero",
},
legend={
"orientation": "h",
"yanchor": "top",
"y": -0.07,
"xanchor": "center",
"x": 0.5,
"title": None,
},
)
return fig
def main() -> None:
df = load()
paths = sys.argv[1:]
if not paths:
build_figure(df).show()
for path, theme in zip(paths, THEMES):
build_figure(df, theme).write_image(path, scale=2)
if __name__ == "__main__":
main()