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.
13<a href="https://img.cleberg.net/blog/salary/salary.webp"> 13<a href="https://img.cleberg.net/blog/salary/salary.webp">
14<picture> 14<picture>
15<source srcset="https://img.cleberg.net/blog/salary/salary-dark.webp" media="(prefers-color-scheme: dark)"> 15<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.">
17</picture> 17</picture>
18</a> 18</a>
19<figcaption>Salary visualization. Click for full size.</figcaption> 19<figcaption>Salary visualization. Click for full size.</figcaption>
utils/salary_visualization.py +55 −43
@@ -15,9 +15,7 @@ import plotly.graph_objs as go
15 15
16CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv" 16CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv"
17 17
18# Surfaces match theme/static/styles.css. Series colors are one per company in 18# Surfaces match theme/static/styles.css.
19# order of first appearance; the dark steps are the same hues re-stepped for
20# the dark surface.
21THEMES = { 19THEMES = {
22 "light": { 20 "light": {
23 "surface": "#ffffff", 21 "surface": "#ffffff",
@@ -25,7 +23,6 @@ THEMES = {
25 "muted": "#52514e", 23 "muted": "#52514e",
26 "grid": "#f0efec", 24 "grid": "#f0efec",
27 "band": "#f8f8f6", 25 "band": "#f8f8f6",
28 "palette": ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"],
29 }, 26 },
30 "dark": { 27 "dark": {
31 "surface": "#181a1b", 28 "surface": "#181a1b",
@@ -33,10 +30,20 @@ THEMES = {
33 "muted": "#c3c2b7", 30 "muted": "#c3c2b7",
34 "grid": "#262829", 31 "grid": "#262829",
35 "band": "#1f2122", 32 "band": "#1f2122",
36 "palette": ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"],
37 }, 33 },
38} 34}
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
40HOURS_PER_YEAR = 2080 47HOURS_PER_YEAR = 2080
41 48
42 49
@@ -62,56 +69,59 @@ def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
62 t = THEMES[theme] 69 t = THEMES[theme]
63 fig = go.Figure() 70 fig = go.Figure()
64 companies = list(dict.fromkeys(df["Company"])) 71 companies = list(dict.fromkeys(df["Company"]))
65 colors = dict(zip(companies, t["palette"])) 72 colors = BRAND
66 73
67 # Thin connectors between a job's end and the next job's start, drawn 74 # Thin connectors between a job's end and the next job's start, as shapes
68 # first so the salary segments sit on top. Concurrent jobs get none. 75 # on the below layer so they never cover a bar's border. Concurrent jobs
76 # get none.
69 ends = {row["End"]: row for _, row in df.iterrows()} 77 ends = {row["End"]: row for _, row in df.iterrows()}
70 for _, row in df.iterrows(): 78 for _, row in df.iterrows():
71 prev = ends.get(row["Start"]) 79 prev = ends.get(row["Start"])
72 if prev is None: 80 if prev is None:
73 continue 81 continue
74 fig.add_trace( 82 fig.add_shape(
75 go.Scatter( 83 type="line",
76 x=[row["Start"], row["Start"]], 84 x0=row["Start"],
77 y=[prev["Salary"], row["Salary"]], 85 x1=row["Start"],
78 mode="lines", 86 y0=prev["Salary"],
79 line={"color": t["grid"], "width": 1.5}, 87 y1=row["Salary"],
80 hoverinfo="skip", 88 line={"color": t["grid"], "width": 1.5},
81 showlegend=False, 89 layer="below",
82 )
83 ) 90 )
84 91
85 # One trace per company and pay type. Hourly jobs are dotted. Legend 92 # One bar trace per company and pay type, drawn as horizontal bars so
86 # entries are separate dummy traces so every company swatch is solid. 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
87 for (company, pay), rows in df.groupby(["Company", "PayType"]): 108 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( 109 fig.add_trace(
94 go.Scatter( 110 go.Bar(
95 x=xs, 111 orientation="h",
96 y=ys, 112 base=rows["Start"],
97 mode="lines", 113 x=(rows["End"] - rows["Start"]).dt.total_seconds() * 1000,
98 line={"color": colors[company], "width": 4, "dash": DASH[pay]}, 114 y=rows["Salary"],
115 width=BAR_HEIGHT,
116 marker=marker(colors[company], pay),
99 hoverinfo="skip", 117 hoverinfo="skip",
100 showlegend=False, 118 showlegend=False,
101 ) 119 )
102 ) 120 )
103 entries = [(c, colors[c], "solid") for c in companies] 121 entries = [(c, colors[c], "salaried") for c in companies]
104 entries += [(pay.capitalize(), t["muted"], dash) for pay, dash in DASH.items()] 122 entries += [(pay.capitalize(), t["muted"], pay) for pay in PATTERN]
105 for name, color, dash in entries: 123 for name, color, pay in entries:
106 fig.add_trace( 124 fig.add_trace(go.Bar(x=[None], y=[None], name=name, marker=marker(color, pay)))
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 125
116 # Direct labels above each segment, starting at the segment's left end so 126 # 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 127 # 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:
131 xanchor="right" if i == last else "left", 141 xanchor="right" if i == last else "left",
132 y=row["Salary"], 142 y=row["Salary"],
133 yanchor="bottom" if above else "top", 143 yanchor="bottom" if above else "top",
134 yshift=5 if above else -5, 144 yshift=6 if above else -6,
135 text=label(row, t["muted"]), 145 text=label(row, t["muted"]),
136 showarrow=False, 146 showarrow=False,
137 font={"color": t["text"], "size": 14}, 147 font={"color": t["text"], "size": 14},
@@ -181,6 +191,7 @@ def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
181 "x": 0.03, 191 "x": 0.03,
182 }, 192 },
183 template="plotly_white", 193 template="plotly_white",
194 barmode="overlay",
184 paper_bgcolor=t["surface"], 195 paper_bgcolor=t["surface"],
185 plot_bgcolor=t["surface"], 196 plot_bgcolor=t["surface"],
186 font={"family": "monospace", "size": 14, "color": t["text"]}, 197 font={"family": "monospace", "size": 14, "color": t["text"]},
@@ -188,6 +199,7 @@ def build_figure(df: pd.DataFrame, theme: str = "light") -> go.Figure:
188 width=1600, 199 width=1600,
189 height=800, 200 height=800,
190 xaxis={ 201 xaxis={
202 "type": "date",
191 "showgrid": True, 203 "showgrid": True,
192 "gridcolor": t["grid"], 204 "gridcolor": t["grid"],
193 "dtick": "M12", 205 "dtick": "M12",