utils/salary_visualization.py
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()