utils/salary_visualization.py

salary-visualization
cleberg.net/utils/salary_visualization.py history · blame · raw

226 lines · 7304 bytes

  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. Series colors are one per company in
 19# order of first appearance; the dark steps are the same hues re-stepped for
 20# the dark surface.
 21THEMES = {
 22    "light": {
 23        "surface": "#ffffff",
 24        "text": "#0b0b0b",
 25        "muted": "#52514e",
 26        "grid": "#f0efec",
 27        "band": "#f8f8f6",
 28        "palette": ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"],
 29    },
 30    "dark": {
 31        "surface": "#181a1b",
 32        "text": "#ffffff",
 33        "muted": "#c3c2b7",
 34        "grid": "#262829",
 35        "band": "#1f2122",
 36        "palette": ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"],
 37    },
 38}
 39DASH = {"salaried": "solid", "hourly": "dot"}
 40HOURS_PER_YEAR = 2080
 41
 42
 43def load() -> pd.DataFrame:
 44    df = pd.read_csv(CSV_PATH)
 45    df["Start"] = pd.to_datetime(df["Start"])
 46    df["End"] = pd.to_datetime(df["End"])
 47    return df.sort_values(["Start", "End"]).reset_index(drop=True)
 48
 49
 50def label(row: pd.Series, muted: str) -> str:
 51    salary = f"${row['Salary']:,.0f}"
 52    detail = row["Title"]
 53    if row["PayType"] == "hourly":
 54        # A second "$" in one string makes plotly parse it as LaTeX.
 55        detail = f"${row['Salary'] / HOURS_PER_YEAR:,.0f}/hr · " + detail
 56    if row["PercentChange"]:
 57        detail += f" · {row['PercentChange'] * 100:+.1f}%"
 58    return f"<b>{salary}</b> <span style='font-size:12px;color:{muted}'>{detail}</span>"
 59
 60
 61def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
 62    t = THEMES[theme]
 63    fig = go.Figure()
 64    companies = list(dict.fromkeys(df["Company"]))
 65    colors = dict(zip(companies, t["palette"]))
 66
 67    # Thin connectors between a job's end and the next job's start, drawn
 68    # first so the salary segments sit on top. Concurrent jobs get none.
 69    ends = {row["End"]: row for _, row in df.iterrows()}
 70    for _, row in df.iterrows():
 71        prev = ends.get(row["Start"])
 72        if prev is None:
 73            continue
 74        fig.add_trace(
 75            go.Scatter(
 76                x=[row["Start"], row["Start"]],
 77                y=[prev["Salary"], row["Salary"]],
 78                mode="lines",
 79                line={"color": t["grid"], "width": 1.5},
 80                hoverinfo="skip",
 81                showlegend=False,
 82            )
 83        )
 84
 85    # One trace per company and pay type. Hourly jobs are dotted. Legend
 86    # entries are separate dummy traces so every company swatch is solid.
 87    for (company, pay), rows in df.groupby(["Company", "PayType"]):
 88        xs: list = []
 89        ys: list = []
 90        for _, row in rows.iterrows():
 91            xs += [row["Start"], row["End"], None]
 92            ys += [row["Salary"], row["Salary"], None]
 93        fig.add_trace(
 94            go.Scatter(
 95                x=xs,
 96                y=ys,
 97                mode="lines",
 98                line={"color": colors[company], "width": 4, "dash": DASH[pay]},
 99                hoverinfo="skip",
100                showlegend=False,
101            )
102        )
103    entries = [(c, colors[c], "solid") for c in companies]
104    entries += [(pay.capitalize(), t["muted"], dash) for pay, dash in DASH.items()]
105    for name, color, dash in entries:
106        fig.add_trace(
107            go.Scatter(
108                x=[None],
109                y=[None],
110                mode="lines",
111                name=name,
112                line={"color": color, "width": 4, "dash": dash},
113            )
114        )
115
116    # Direct labels above each segment, starting at the segment's left end so
117    # they extend over the empty space under the next, higher step. The last
118    # segment anchors on its right end so the label stays inside the plot. A
119    # label moves below its segment when a later segment at a similar salary
120    # starts within the label's reach (the concurrent 2017 jobs).
121    y_gap = df["Salary"].max() * 0.035
122    last = len(df) - 1
123    for i, row in df.iterrows():
124        later = df.iloc[i + 1 :]
125        above = not (
126            ((later["Start"] - row["Start"]).dt.days < 400)
127            & ((later["Salary"] - row["Salary"]).abs() < y_gap)
128        ).any()
129        fig.add_annotation(
130            x=row["End"] if i == last else row["Start"],
131            xanchor="right" if i == last else "left",
132            y=row["Salary"],
133            yanchor="bottom" if above else "top",
134            yshift=5 if above else -5,
135            text=label(row, t["muted"]),
136            showarrow=False,
137            font={"color": t["text"], "size": 14},
138        )
139
140    # Shade everything after today so the current job's end reads as projected.
141    today = datetime.now(tz=UTC).date()
142    fig.add_vrect(
143        x0=today,
144        x1=df["End"].max(),
145        fillcolor=t["band"],
146        line_width=0,
147        layer="below",
148    )
149    fig.add_annotation(
150        x=today,
151        y=0,
152        yref="paper",
153        text="today",
154        showarrow=False,
155        xanchor="left",
156        xshift=4,
157        yanchor="bottom",
158        yshift=4,
159        font={"color": t["muted"], "size": 12},
160    )
161    fig.add_annotation(
162        x=0,
163        y=-0.2,
164        xref="paper",
165        yref="paper",
166        text=(
167            f"Hourly rates annualized at {HOURS_PER_YEAR:,} hours. "
168            "Percent change is against the preceding job. "
169            "Shaded area is after today."
170        ),
171        showarrow=False,
172        xanchor="left",
173        yanchor="top",
174        font={"color": t["muted"], "size": 12},
175    )
176
177    pad = pd.Timedelta(days=180)
178    fig.update_layout(
179        title={
180            "text": f"Annualized pay by job, {df['Start'].min().year}–{df['End'].max().year}",
181            "x": 0.03,
182        },
183        template="plotly_white",
184        paper_bgcolor=t["surface"],
185        plot_bgcolor=t["surface"],
186        font={"family": "monospace", "size": 14, "color": t["text"]},
187        margin={"l": 90, "r": 40, "t": 70, "b": 150},
188        width=1600,
189        height=800,
190        xaxis={
191            "showgrid": True,
192            "gridcolor": t["grid"],
193            "dtick": "M12",
194            "tickformat": "%Y",
195            "linecolor": t["muted"],
196            "range": [df["Start"].min() - pad, df["End"].max() + pad],
197        },
198        yaxis={
199            "showgrid": True,
200            "gridcolor": t["grid"],
201            "tickformat": "$,.0f",
202            "rangemode": "tozero",
203        },
204        legend={
205            "orientation": "h",
206            "yanchor": "top",
207            "y": -0.09,
208            "xanchor": "center",
209            "x": 0.5,
210            "title": None,
211        },
212    )
213    return fig
214
215
216def main() -> None:
217    df = load()
218    paths = sys.argv[1:]
219    if not paths:
220        build_figure(df).show()
221    for path, theme in zip(paths, THEMES):
222        build_figure(df, theme).write_image(path, scale=2)
223
224
225if __name__ == "__main__":
226    main()