Commit a99b687218
Verified · cmc ci/build: success ci/lint: success
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 | ||
| 16 | CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv" | 16 | CSV_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. | ||
| 21 | THEMES = { | 19 | THEMES = { |
| 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 | } |
| 39 | DASH = {"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. | ||
| 37 | BRAND = { | ||
| 38 | "Walgreens": "#5fb3e8", | ||
| 39 | "University of Nebraska": "#9b0000", | ||
| 40 | "Ameritas": "#d51e22", | ||
| 41 | "Nelnet": "#afd135", | ||
| 42 | "Ernst & Young": "#ffe600", | ||
| 43 | "KPMG": "#00338d", | ||
| 44 | } | ||
| 45 | PATTERN = {"salaried": "", "hourly": "/"} | ||
| 46 | BAR_HEIGHT = 1800 # in dollars, about 6px at the default figure size | ||
| 40 | HOURS_PER_YEAR = 2080 | 47 | HOURS_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", |