Rework the salary visualization !5

merged merged by cmc on 2026-10-07 22:48 UTC · cmc/cleberg.net:salary-visualization into main

3 files changed, +239 −94

Layout: unified · split

content/salary/index.org +12 −3
@@ -21,6 +21,7 @@ screens. For the full dataset with all fields, you can view the [[https://cleber
21 21
22| Title | Department | Company | Year | Salary | 22| Title | Department | Company | Year | Salary |
23|-----------------------------+------------------------------+-----------+------+----------| 23|-----------------------------+------------------------------+-----------+------+----------|
24| Manager | Technology Assurance - Audit | [[https://kpmg.com/us/][KPMG]] | 2026 | $158,000 |
24| Manager | Technology Assurance - Audit | [[https://kpmg.com/us/][KPMG]] | 2025 | $147,600 | 25| Manager | Technology Assurance - Audit | [[https://kpmg.com/us/][KPMG]] | 2025 | $147,600 |
25| Manager | Technology Assurance - Audit | [[https://kpmg.com/us/][KPMG]] | 2024 | $141,200 | 26| Manager | Technology Assurance - Audit | [[https://kpmg.com/us/][KPMG]] | 2024 | $141,200 |
26| Senior | Technology Assurance - Audit | [[https://kpmg.com/us/][KPMG]] | 2023 | $116,700 | 27| Senior | Technology Assurance - Audit | [[https://kpmg.com/us/][KPMG]] | 2023 | $116,700 |
@@ -36,9 +37,17 @@ screens. For the full dataset with all fields, you can view the [[https://cleber
36 37
37** Salary Visualization 38** Salary Visualization
38 39
39#+caption: Salary Visualization 40#+begin_export html
40#+attr_html: :alt A visualization of salary data over time from the table above. 41<figure>
41[[https://img.cleberg.net/blog/salary/salary.webp]] 42<a href="https://img.cleberg.net/blog/salary/salary.webp">
43<picture>
44<source srcset="https://img.cleberg.net/blog/salary/salary-dark.webp" media="(prefers-color-scheme: dark)">
45<img src="https://img.cleberg.net/blog/salary/salary.webp" alt="Step chart of annualized pay by job from 2017 to 2027. Each job is a horizontal segment colored by company and labeled with its pay and title. Hourly jobs are dotted and show the hourly rate. The area after today is shaded.">
46</picture>
47</a>
48<figcaption>Salary visualization. Click for full size.</figcaption>
49</figure>
50#+end_export
42 51
43[[https://xeiaso.net/salary-transparency/][Xe Iaso]] put it better than I would: 52[[https://xeiaso.net/salary-transparency/][Xe Iaso]] put it better than I would:
44 53
theme/static/salary.csv +14 −13
@@ -1,13 +1,14 @@
1Title,Department,Company,Location,Start,End,Salary,PercentChange 1Title,Department,Company,Location,Start,End,Salary,PercentChange,PayType
2Manager,Technology Assurance - Audit,KPMG,Omaha(NE),2025-10,2026-10,147600,0.0453 2Manager,Technology Assurance - Audit,KPMG,Omaha(NE),2026-10,2027-10,158000,0.0705,salaried
3Manager,Technology Assurance - Audit,KPMG,Omaha(NE),2024-10,2025-10,141200,0.2099 3Manager,Technology Assurance - Audit,KPMG,Omaha(NE),2025-10,2026-10,147600,0.0453,salaried
4Senior,Technology Assurance - Audit,KPMG,Omaha(NE),2023-10,2024-10,116700,0.0609 4Manager,Technology Assurance - Audit,KPMG,Omaha(NE),2024-10,2025-10,141200,0.2099,salaried
5Senior,Technology Assurance - Audit,KPMG,Omaha(NE),2022-06,2023-10,110000,0.2290 5Senior,Technology Assurance - Audit,KPMG,Omaha(NE),2023-10,2024-10,116700,0.0609,salaried
6Senior,Technology Risk - Consulting,Ernst & Young,Des Moines(IA),2021-09,2022-06,89500,0.2430 6Senior,Technology Assurance - Audit,KPMG,Omaha(NE),2022-06,2023-10,110000,0.2290,salaried
7Senior IT Auditor,Internal Audit,Ameritas,Lincoln(NE),2021-05,2021-09,72000,0.1076 7Senior,Technology Risk - Consulting,Ernst & Young,Des Moines(IA),2021-09,2022-06,89500,0.2430,salaried
8IT Auditor,Internal Audit,Ameritas,Lincoln(NE),2020-04,2021-05,65000,0.1206 8Senior IT Auditor,Internal Audit,Ameritas,Lincoln(NE),2021-05,2021-09,72000,0.1076,salaried
9IS Auditor II,Internal Audit,Nelnet,Lincoln(NE),2019-12,2020-04,58000,0.3942 9IT Auditor,Internal Audit,Ameritas,Lincoln(NE),2020-04,2021-05,65000,0.1206,salaried
10IS Auditor I,Internal Audit,Nelnet,Lincoln(NE),2019-06,2019-12,41600,0.2500 10IS Auditor II,Internal Audit,Nelnet,Lincoln(NE),2019-12,2020-04,58000,0.3942,salaried
11Internal Audit Intern,Internal Audit,Ameritas,Lincoln(NE),2018-02,2019-06,33280,1.2857 11IS Auditor I,Internal Audit,Nelnet,Lincoln(NE),2019-06,2019-12,41600,0.2500,hourly
12Teaching Assistant,College of Business,University of Nebraska,Lincoln(NE),2017-08,2018-05,14560,-0.5000 12Internal Audit Intern,Internal Audit,Ameritas,Lincoln(NE),2018-02,2019-06,33280,0.1429,hourly
13Community Management Intern,Store/Retail,Walgreens,Lincoln(NE),2017-06,2018-02,29120,0.0000 13Teaching Assistant,College of Business,University of Nebraska,Lincoln(NE),2017-08,2018-05,14560,0.0000,hourly
14Community Management Intern,Store/Retail,Walgreens,Lincoln(NE),2017-06,2018-02,29120,0.0000,hourly
utils/salary_visualization.py +213 −78
@@ -1,91 +1,226 @@
1""" 1"""
2A file that will visualize salary data from an incoming CSV. 2Visualize salary data from theme/static/salary.csv with plotly.
3 3
4This script reads a CSV file containing salary data and visualizes it using 4Usage:
5plotly. 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
6""" 8"""
7 9
8import locale 10import sys
11from datetime import UTC, datetime
9 12
10import pandas as pd 13import pandas as pd
11import plotly.graph_objs as go 14import plotly.graph_objs as go
12from pandas import read_csv as pd_read_csv
13 15
14locale.setlocale(locale.LC_ALL, "en_US.UTF-8") 16CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv"
15 17
16# Read the CSV file 18# Surfaces match theme/static/styles.css. Series colors are one per company in
17df = pd_read_csv("~/git/cleberg.net/theme/static/salary.csv") 19# order of first appearance; the dark steps are the same hues re-stepped for
18 20# the dark surface.
19 21THEMES = {
20def format_currency(value: float) -> str: 22 "light": {
21 """ 23 "surface": "#ffffff",
22 Format values in USD currency format. 24 "text": "#0b0b0b",
23 25 "muted": "#52514e",
24 Args: 26 "grid": "#f0efec",
25 value (float): The value to be formatted. 27 "band": "#f8f8f6",
26 28 "palette": ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"],
27 Returns: 29 },
28 str: The formatted value. 30 "dark": {
29 """ 31 "surface": "#181a1b",
30 return f"${value:,.2f}" 32 "text": "#ffffff",
31 33 "muted": "#c3c2b7",
32 34 "grid": "#262829",
33# Reverse the order of the DataFrame 35 "band": "#1f2122",
34df = df.iloc[::-1].reset_index(drop=True) 36 "palette": ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"],
35 37 },
36# Calculate the percentage increase 38}
37df["Percentage Increase"] = df["Salary"].pct_change() * 100 39DASH = {"salaried": "solid", "hourly": "dot"}
38 40HOURS_PER_YEAR = 2080
39 41
40def create_trace(row: pd.Series) -> go.Scatter: 42
41 """ 43def load() -> pd.DataFrame:
42 Create a scatter plot trace for a single row. 44 df = pd.read_csv(CSV_PATH)
43 45 df["Start"] = pd.to_datetime(df["Start"])
44 Args: 46 df["End"] = pd.to_datetime(df["End"])
45 row (pd.Series): The row to be plotted. 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"&#36;{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 )
46 176
47 Returns: 177 pad = pd.Timedelta(days=180)
48 go.Scatter: The created scatter plot trace. 178 fig.update_layout(
49 """ 179 title={
50 title_company = f"{row['Title']} ({row['Company']})" 180 "text": f"Annualized pay by job, {df['Start'].min().year}–{df['End'].max().year}",
51 salary_formatted = format_currency(row["Salary"]) 181 "x": 0.03,
52 if pd.notna(row["Percentage Increase"]): 182 },
53 text = f"{salary_formatted} ({row['Percentage Increase']:.2f}%)" 183 template="plotly_white",
54 else: 184 paper_bgcolor=t["surface"],
55 text = salary_formatted 185 plot_bgcolor=t["surface"],
56 return go.Scatter( 186 font={"family": "monospace", "size": 14, "color": t["text"]},
57 x=[row["Start"], row["End"]], 187 margin={"l": 90, "r": 40, "t": 70, "b": 150},
58 y=[row["Salary"], row["Salary"]], 188 width=1600,
59 text=[text], 189 height=800,
60 mode="lines+text", 190 xaxis={
61 name=title_company, 191 "showgrid": True,
62 textposition="top center", 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 },
63 ) 212 )
213 return fig
64 214
65 215
66# Initialize the plot 216def main() -> None:
67fig = go.Figure() 217 df = load()
68 218 paths = sys.argv[1:]
69# Add each data point as a separate trace to display the text 219 if not paths:
70for index, df_row in df.iterrows(): 220 build_figure(df).show()
71 fig.add_trace(create_trace(df_row)) 221 for path, theme in zip(paths, THEMES):
72 222 build_figure(df, theme).write_image(path, scale=2)
73# Update visual styles of the figure 223
74fig.update_layout(
75 title="Salary Data Over Time (annualized)",
76 xaxis_title="Time",
77 yaxis_title="Salary",
78 font={"family": "monospace", "size": 16},
79 margin={"l": 50, "r": 50, "t": 50, "b": 100},
80 legend={
81 "orientation": "h",
82 "yanchor": "top",
83 "y": -0.3,
84 "xanchor": "center",
85 "x": 0.5,
86 },
87 height=800,
88)
89 224
90# Display the plot 225if __name__ == "__main__":
91fig.show() 226 main()