| @@ -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 |
| 5 | | plotly. |
| 4 | Usage: |
| 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 | 13 | import pandas as pd |
| 11 | 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") |
| 15 | | |
| 16 | | # Read the CSV file |
| 17 | | df = pd_read_csv("~/git/cleberg.net/theme/static/salary.csv") |
| 18 | | |
| 19 | | |
| 20 | | def format_currency(value: float) -> str: |
| 21 | | """ |
| 22 | | Format values in USD currency format. |
| 23 | | |
| 24 | | Args: |
| 25 | | value (float): The value to be formatted. |
| 26 | | |
| 27 | | Returns: |
| 28 | | str: The formatted value. |
| 29 | | """ |
| 30 | | return f"${value:,.2f}" |
| 31 | | |
| 32 | | |
| 33 | | # Reverse the order of the DataFrame |
| 34 | | df = df.iloc[::-1].reset_index(drop=True) |
| 35 | | |
| 36 | | # Calculate the percentage increase |
| 37 | | df["Percentage Increase"] = df["Salary"].pct_change() * 100 |
| 38 | | |
| 39 | | |
| 40 | | def create_trace(row: pd.Series) -> go.Scatter: |
| 41 | | """ |
| 42 | | Create a scatter plot trace for a single row. |
| 43 | | |
| 44 | | Args: |
| 45 | | row (pd.Series): The row to be plotted. |
| 16 | CSV_PATH = "~/git/cmc/cleberg.net/theme/static/salary.csv" |
| 17 | |
| 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. |
| 21 | THEMES = { |
| 22 | "light": { |
| 23 | "surface": "#ffffff", |
| 24 | "text": "#0b0b0b", |
| 25 | "muted": "#52514e", |
| 26 | "grid": "#f0efec", |
| 27 | "band": "#f8f8f6", |
| 28 | "palette": ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"], |
| 29 | }, |
| 30 | "dark": { |
| 31 | "surface": "#181a1b", |
| 32 | "text": "#ffffff", |
| 33 | "muted": "#c3c2b7", |
| 34 | "grid": "#262829", |
| 35 | "band": "#1f2122", |
| 36 | "palette": ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"], |
| 37 | }, |
| 38 | } |
| 39 | DASH = {"salaried": "solid", "hourly": "dot"} |
| 40 | HOURS_PER_YEAR = 2080 |
| 41 | |
| 42 | |
| 43 | def load() -> pd.DataFrame: |
| 44 | df = pd.read_csv(CSV_PATH) |
| 45 | df["Start"] = pd.to_datetime(df["Start"]) |
| 46 | df["End"] = pd.to_datetime(df["End"]) |
| 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: |
| 48 | | go.Scatter: The created scatter plot trace. |
| 49 | | """ |
| 50 | | title_company = f"{row['Title']} ({row['Company']})" |
| 51 | | salary_formatted = format_currency(row["Salary"]) |
| 52 | | if pd.notna(row["Percentage Increase"]): |
| 53 | | text = f"{salary_formatted} ({row['Percentage Increase']:.2f}%)" |
| 54 | | else: |
| 55 | | text = salary_formatted |
| 56 | | return go.Scatter( |
| 57 | | x=[row["Start"], row["End"]], |
| 58 | | y=[row["Salary"], row["Salary"]], |
| 59 | | text=[text], |
| 60 | | mode="lines+text", |
| 61 | | name=title_company, |
| 62 | | textposition="top center", |
| 177 | pad = pd.Timedelta(days=180) |
| 178 | fig.update_layout( |
| 179 | title={ |
| 180 | "text": f"Annualized pay by job, {df['Start'].min().year}–{df['End'].max().year}", |
| 181 | "x": 0.03, |
| 182 | }, |
| 183 | template="plotly_white", |
| 184 | paper_bgcolor=t["surface"], |
| 185 | plot_bgcolor=t["surface"], |
| 186 | font={"family": "monospace", "size": 14, "color": t["text"]}, |
| 187 | margin={"l": 90, "r": 40, "t": 70, "b": 150}, |
| 188 | width=1600, |
| 189 | height=800, |
| 190 | xaxis={ |
| 191 | "showgrid": True, |
| 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 |
| 67 | | fig = go.Figure() |
| 68 | | |
| 69 | | # Add each data point as a separate trace to display the text |
| 70 | | for index, df_row in df.iterrows(): |
| 71 | | fig.add_trace(create_trace(df_row)) |
| 72 | | |
| 73 | | # Update visual styles of the figure |
| 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 | | ) |
| 216 | def main() -> None: |
| 217 | df = load() |
| 218 | paths = sys.argv[1:] |
| 219 | if not paths: |
| 220 | build_figure(df).show() |
| 221 | for path, theme in zip(paths, THEMES): |
| 222 | build_figure(df, theme).write_image(path, scale=2) |
| 223 | |
| 89 | 224 | |
| 90 | | # Display the plot |
| 91 | | fig.show() |
| 225 | if __name__ == "__main__": |
| 226 | main() |