| @@ -1,91 +1,226 @@ |
| 1 | """ |
1 | """ |
| 2 | A file that will visualize salary data from an incoming CSV. |
2 | Visualize salary data from theme/static/salary.csv with plotly. |
| 3 | |
3 | |
| 4 | This script reads a CSV file containing salary data and visualizes it using |
4 | Usage: |
| 5 | plotly. |
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 | |
| 8 | import locale |
10 | import sys |
| |
11 | from datetime import UTC, datetime |
| 9 | |
12 | |
| 10 | import pandas as pd |
13 | import pandas as pd |
| 11 | import plotly.graph_objs as go |
14 | import plotly.graph_objs as go |
| 12 | from pandas import read_csv as pd_read_csv |
| |
| 13 | |
15 | |
| 14 | locale.setlocale(locale.LC_ALL, "en_US.UTF-8") |
16 | CSV_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 |
| 17 | df = 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 | |
21 | THEMES = { |
| 20 | def 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", |
| 34 | df = df.iloc[::-1].reset_index(drop=True) |
36 | "palette": ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"], |
| 35 | |
37 | }, |
| 36 | # Calculate the percentage increase |
38 | } |
| 37 | df["Percentage Increase"] = df["Salary"].pct_change() * 100 |
39 | DASH = {"salaried": "solid", "hourly": "dot"} |
| 38 | |
40 | HOURS_PER_YEAR = 2080 |
| 39 | |
41 | |
| 40 | def create_trace(row: pd.Series) -> go.Scatter: |
42 | |
| 41 | """ |
43 | def 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 | |
| |
50 | def 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"${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 | |
| |
61 | def 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 |
216 | def main() -> None: |
| 67 | fig = 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: |
| 70 | for 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 | |
| 74 | fig.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 |
225 | if __name__ == "__main__": |
| 91 | fig.show() |
226 | main() |