Commit a99b687218

a99b687218a53cf31d2b54985d6d192737c6fc94

parent: 85aaafbc54

Verified · cmc ci/build: success ci/lint: success

cmc <hello@cleberg.net> · 2026-10-07 23:08 UTC

Use employer brand colors and bordered bars

Draw each job as a horizontal bar with a border and hatch the hourly ones.
Connectors move to below-layer shapes so they do not cover the borders.

Layout: unified · split

content/salary/index.org +1 −1
@@ -13,7 +13,7 @@ what this page is for: I believe the playing field should be level.
1313<a href="https://img.cleberg.net/blog/salary/salary.webp">
1414<picture>
1515<source srcset="https://img.cleberg.net/blog/salary/salary-dark.webp" media="(prefers-color-scheme: dark)">
16<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.">
16<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 hatched and show the hourly rate. The area after today is shaded.">
1717</picture>
1818</a>
1919<figcaption>Salary visualization. Click for full size.</figcaption>
utils/salary_visualization.py +55 −43
@@ -15,9 +15,7 @@ import plotly.graph_objs as go
1515
1616CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv"
1717
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.
18# Surfaces match theme/static/styles.css.
2119THEMES = {
2220 "light": {
2321 "surface": "#ffffff",
@@ -25,7 +23,6 @@ THEMES = {
2523 "muted": "#52514e",
2624 "grid": "#f0efec",
2725 "band": "#f8f8f6",
28 "palette": ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"],
2926 },
3027 "dark": {
3128 "surface": "#181a1b",
@@ -33,10 +30,20 @@ THEMES = {
3330 "muted": "#c3c2b7",
3431 "grid": "#262829",
3532 "band": "#1f2122",
36 "palette": ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"],
3733 },
3834}
39DASH = {"salaried": "solid", "hourly": "dot"}
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 = 1800 # in dollars, about 6px at the default figure size
4047HOURS_PER_YEAR = 2080
4148
4249
@@ -62,56 +69,59 @@ def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
6269 t = THEMES[theme]
6370 fig = go.Figure()
6471 companies = list(dict.fromkeys(df["Company"]))
65 colors = dict(zip(companies, t["palette"]))
72 colors = BRAND
6673
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.
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.
6977 ends = {row["End"]: row for _, row in df.iterrows()}
7078 for _, row in df.iterrows():
7179 prev = ends.get(row["Start"])
7280 if prev is None:
7381 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 )
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",
8390 )
8491
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.
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
87108 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]
93109 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]},
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),
99117 hoverinfo="skip",
100118 showlegend=False,
101119 )
102120 )
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 )
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)))
115125
116126 # Direct labels above each segment, starting at the segment's left end so
117127 # they extend over the empty space under the next, higher step. The last
@@ -131,7 +141,7 @@ def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
131141 xanchor="right" if i == last else "left",
132142 y=row["Salary"],
133143 yanchor="bottom" if above else "top",
134 yshift=5 if above else -5,
144 yshift=6 if above else -6,
135145 text=label(row, t["muted"]),
136146 showarrow=False,
137147 font={"color": t["text"], "size": 14},
@@ -181,6 +191,7 @@ def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
181191 "x": 0.03,
182192 },
183193 template="plotly_white",
194 barmode="overlay",
184195 paper_bgcolor=t["surface"],
185196 plot_bgcolor=t["surface"],
186197 font={"family": "monospace", "size": 14, "color": t["text"]},
@@ -188,6 +199,7 @@ def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
188199 width=1600,
189200 height=800,
190201 xaxis={
202 "type": "date",
191203 "showgrid": True,
192204 "gridcolor": t["grid"],
193205 "dtick": "M12",