utils/salary_visualization.py

2272f4bf412ab26c57517e9c369f8859cd269bb1
cleberg.net/utils/salary_visualization.py history · blame · raw

239 lines · 7579 bytes

5 symbols in this file
  1"""
  2Visualize salary data from theme/static/salary.csv with plotly.
  3
  4Usage:
  5    python utils/salary_visualization.py                      # open in a browser
  6    python utils/salary_visualization.py light.webp           # write an image (needs kaleido)
  7    python utils/salary_visualization.py light.webp dark.webp # light and dark variants
  8"""
  9
 10import sys
 11from datetime import UTC, datetime
 12
 13import pandas as pd
 14import plotly.graph_objs as go
 15
 16CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv"
 17
 18# Surfaces match theme/static/styles.css.
 19THEMES = {
 20    "light": {
 21        "surface": "#ffffff",
 22        "text": "#0b0b0b",
 23        "muted": "#52514e",
 24        "grid": "#f0efec",
 25        "band": "#f8f8f6",
 26    },
 27    "dark": {
 28        "surface": "#181a1b",
 29        "text": "#ffffff",
 30        "muted": "#c3c2b7",
 31        "grid": "#262829",
 32        "band": "#1f2122",
 33    },
 34}
 35# Each employer's brand color. Walgreens is the uniform blue rather than the
 36# logo red; Nebraska is darkened to separate it from Ameritas.
 37BRAND = {
 38    "Walgreens": "#5fb3e8",
 39    "University of Nebraska": "#9b0000",
 40    "Ameritas": "#d51e22",
 41    "Nelnet": "#afd135",
 42    "Ernst & Young": "#ffe600",
 43    "KPMG": "#00338d",
 44}
 45PATTERN = {"salaried": "", "hourly": "/"}
 46BAR_HEIGHT = 1400  # in dollars, about 6px at the default figure size
 47HOURS_PER_YEAR = 2080
 48
 49
 50def load() -> pd.DataFrame:
 51    df = pd.read_csv(CSV_PATH)
 52    df["Start"] = pd.to_datetime(df["Start"])
 53    df["End"] = pd.to_datetime(df["End"])
 54    return df.sort_values(["Start", "End"]).reset_index(drop=True)
 55
 56
 57def label(row: pd.Series, muted: str) -> str:
 58    salary = f"${row['Salary']:,.0f}"
 59    detail = row["Title"]
 60    if row["PayType"] == "hourly":
 61        # A second "$" in one string makes plotly parse it as LaTeX.
 62        detail = f"${row['Salary'] / HOURS_PER_YEAR:,.0f}/hr · " + detail
 63    if row["PercentChange"]:
 64        detail += f" · {row['PercentChange'] * 100:+.1f}%"
 65    return f"<b>{salary}</b> <span style='font-size:12px;color:{muted}'>{detail}</span>"
 66
 67
 68def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
 69    t = THEMES[theme]
 70    fig = go.Figure()
 71    companies = list(dict.fromkeys(df["Company"]))
 72    colors = BRAND
 73
 74    # Thin connectors between a job's end and the next job's start, as shapes
 75    # on the below layer so they never cover a bar's border. Concurrent jobs
 76    # get none.
 77    ends = {row["End"]: row for _, row in df.iterrows()}
 78    for _, row in df.iterrows():
 79        prev = ends.get(row["Start"])
 80        if prev is None:
 81            continue
 82        fig.add_shape(
 83            type="line",
 84            x0=row["Start"],
 85            x1=row["Start"],
 86            y0=prev["Salary"],
 87            y1=row["Salary"],
 88            line={"color": t["grid"], "width": 1.5},
 89            layer="below",
 90        )
 91
 92    # One bar trace per company and pay type, drawn as horizontal bars so
 93    # each segment gets a border. Hourly jobs are hatched. Legend entries are
 94    # separate dummy traces so every company swatch is solid.
 95    def marker(color: str, pay: str) -> dict:
 96        return {
 97            "color": color,
 98            "line": {"color": t["text"], "width": 1},
 99            "pattern": {
100                "shape": PATTERN[pay],
101                "fgcolor": t["surface"],
102                "bgcolor": color,
103                "size": 5,
104                "solidity": 0.4,
105            },
106        }
107
108    for (company, pay), rows in df.groupby(["Company", "PayType"]):
109        fig.add_trace(
110            go.Bar(
111                orientation="h",
112                base=rows["Start"],
113                x=(rows["End"] - rows["Start"]).dt.total_seconds() * 1000,
114                y=rows["Salary"],
115                width=BAR_HEIGHT,
116                marker=marker(colors[company], pay),
117                hoverinfo="skip",
118                showlegend=False,
119            )
120        )
121    entries = [(c, colors[c], "salaried") for c in companies]
122    entries += [(pay.capitalize(), t["muted"], pay) for pay in PATTERN]
123    for name, color, pay in entries:
124        fig.add_trace(go.Bar(x=[None], y=[None], name=name, marker=marker(color, pay)))
125
126    # Direct labels above each segment, starting at the segment's left end so
127    # they extend over the empty space under the next, higher step. The last
128    # segment anchors on its right end so the label stays inside the plot. A
129    # label moves below its segment when a later segment at a similar salary
130    # starts within the label's reach (the concurrent 2017 jobs).
131    y_gap = df["Salary"].max() * 0.035
132    last = len(df) - 1
133    for i, row in df.iterrows():
134        later = df.iloc[i + 1 :]
135        above = not (
136            ((later["Start"] - row["Start"]).dt.days < 400)
137            & ((later["Salary"] - row["Salary"]).abs() < y_gap)
138        ).any()
139        fig.add_annotation(
140            x=row["End"] if i == last else row["Start"],
141            xanchor="right" if i == last else "left",
142            y=row["Salary"],
143            yanchor="bottom" if above else "top",
144            yshift=6 if above else -6,
145            text=label(row, t["muted"]),
146            showarrow=False,
147            font={"color": t["text"], "size": 14},
148        )
149
150    # Shade everything after today so the current job's end reads as projected.
151    today = datetime.now(tz=UTC).date()
152    fig.add_vrect(
153        x0=today,
154        x1=df["End"].max(),
155        fillcolor=t["band"],
156        line_width=0,
157        layer="below",
158    )
159    fig.add_annotation(
160        x=today,
161        y=0,
162        yref="paper",
163        text="today",
164        showarrow=False,
165        xanchor="left",
166        xshift=4,
167        yanchor="bottom",
168        yshift=4,
169        font={"color": t["muted"], "size": 12},
170    )
171    fig.add_annotation(
172        x=0,
173        y=0,
174        yshift=-105,
175        xref="paper",
176        yref="paper",
177        text=(
178            f"Hourly rates annualized at {HOURS_PER_YEAR:,} hours. "
179            "Percent change is against the preceding job. "
180            "Shaded area is after today."
181        ),
182        showarrow=False,
183        xanchor="left",
184        yanchor="top",
185        font={"color": t["muted"], "size": 12},
186    )
187
188    pad = pd.Timedelta(days=180)
189    fig.update_layout(
190        title={
191            "text": f"Annualized pay by job, {df['Start'].min().year}–{df['End'].max().year}",
192            "x": 0.03,
193        },
194        template="plotly_white",
195        barmode="overlay",
196        paper_bgcolor=t["surface"],
197        plot_bgcolor=t["surface"],
198        font={"family": "monospace", "size": 14, "color": t["text"]},
199        margin={"l": 90, "r": 40, "t": 70, "b": 140},
200        width=1600,
201        height=1000,
202        xaxis={
203            "type": "date",
204            "showgrid": True,
205            "gridcolor": t["grid"],
206            "dtick": "M12",
207            "tickformat": "%Y",
208            "linecolor": t["muted"],
209            "range": [df["Start"].min() - pad, df["End"].max() + pad],
210        },
211        yaxis={
212            "showgrid": True,
213            "gridcolor": t["grid"],
214            "tickformat": "$,.0f",
215            "rangemode": "tozero",
216        },
217        legend={
218            "orientation": "h",
219            "yanchor": "top",
220            "y": -0.07,
221            "xanchor": "center",
222            "x": 0.5,
223            "title": None,
224        },
225    )
226    return fig
227
228
229def main() -> None:
230    df = load()
231    paths = sys.argv[1:]
232    if not paths:
233        build_figure(df).show()
234    for path, theme in zip(paths, THEMES):
235        build_figure(df, theme).write_image(path, scale=2)
236
237
238if __name__ == "__main__":
239    main()