import matplotlib
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
matplotlib.rcParams["hatch.linewidth"] = 2.2
matplotlib.rcParams["font.family"] = "Poppins"
CENTER_COLORS = {
"Daybreak": "#036fa8",
"Saratoga Springs Downtown": "#ea7b00",
"The Point": "#007426",
"Ogden Downtown": "#5f4494",
"Clearfield": "#c9a000",
"Farmington Station Park": "#d32f00",
}
CENTER_LABELS = {
"Daybreak": "Daybreak",
"Saratoga Springs Downtown": "Saratoga Springs\nDowntown",
"The Point": "The Point",
"Ogden Downtown": "Ogden Downtown",
"Clearfield": "Clearfield",
"Farmington Station Park": "Farmington\nStation Park",
}
SURFACE = "#fcfcfb"
TEXT_PRIMARY = "#151515"
TEXT_MUTED = "#7f7a76"
BRACKET_INK = "#a19b94" # neutral connecting line -- just context, not identity
OFFSCALE_CAP = "#1b5e20" # end-cap tick beside the off-scale arrow -- dark green, distinct from BRACKET_INK
LEFT, RIGHT = 0.205, 0.92
# Hide the "Full REMM capacity" tier (track bar + bold value label + legend
# entry + subtitle mention) from the rendered chart without deleting the
# computation -- flip back to True to restore it exactly as it was.
SHOW_CAPACITY_TIER = False
# Scenarios get their own fixed hue, one shared ramp reused across both
# domains and every center. Teal: the one hue-family gap left once all 6
# centers claim blue/orange/green/purple/gold/red (see CENTER_COLORS) --
# distinct from every one of them and from the neutral bracket ink.
SCENARIO_LOW, SCENARIO_MID, SCENARIO_HIGH = "#5cbdb0", "#2f9c8f", "#155c53"
DOMAIN_CFG = {
"jobs": {
"col": "nonres", "summary_cap": "jobs_capacity", "summary_2055": "jobs_2055", "summary_2025": "jobs_2025",
"alt_scenarios": ["far_0.75", "far_4.0"], "alt_ticks": ["0.75 FAR", "4.0 FAR"],
"scenario_colors": {"0.75 FAR": SCENARIO_LOW, "4.0 FAR": SCENARIO_HIGH},
"title": "Employment capacity across six WCV centers",
"subtitle": "2025 estimate, REMM's 2055 forecast, full buildout capacity, and alternate FAR scenarios",
"out": "employment_buildout.png",
},
"housing": {
"col": "residential", "summary_cap": "housing_capacity", "summary_2055": "housing_2055", "summary_2025": "housing_2025",
"alt_scenarios": ["dua_50", "dua_100", "dua_150"], "alt_ticks": ["50 du/ac", "100 du/ac", "150 du/ac"],
"scenario_colors": {"50 du/ac": SCENARIO_LOW, "100 du/ac": SCENARIO_MID, "150 du/ac": SCENARIO_HIGH},
"title": "Housing capacity across six WCV centers",
"subtitle": "2025 estimate, REMM's 2055 forecast, full buildout capacity, and alternate DUA scenarios",
"out": "housing_buildout.png",
},
}
def lighten(hex_color, amount):
"""Blend hex_color toward white by `amount` (0=no change, 1=white)."""
hex_color = hex_color.lstrip("#")
r, g, b = (int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
r, g, b = (int(c + (255 - c) * amount) for c in (r, g, b))
return f"#{r:02x}{g:02x}{b:02x}"
def declutter_depths(xs, min_gap):
"""Given x-positions (any order), return a same-length list of stack
depths (0, 1, 2, ...) so any two positions closer than min_gap land at
different depths -- greedy, walked in x order. Used to keep same-row
labels from overlapping when their underlying values happen to sit close
together (e.g. Ogden Downtown's 2025/2055/FAR-scenario jobs figures all
landing within ~1-2% of each other) -- a collision the original 3-center
data never triggered, but real numbers for the added centers did."""
order_idx = sorted(range(len(xs)), key=lambda i: xs[i])
depths = [0] * len(xs)
last_x, depth = None, 0
for i in order_idx:
x = xs[i]
depth = depth + 1 if last_x is not None and (x - last_x) < min_gap else 0
depths[i] = depth
last_x = x
return depths
# fixed to CENTERS_OF_INTEREST, reversed since barh renders list[0] at the
# bottom row and list[-1] at the top -- this reads top-to-bottom in
# CENTERS_OF_INTEREST's own order in both charts.
CENTER_ORDER = list(reversed(CENTERS_OF_INTEREST))
# Absolute-inches header/footer/title/legend budget, calibrated once from
# the chart's original 3-center, fig_height=6.22in layout (row_gap=1.2, top
# margin fraction 0.68, bottom 0.08, title/subtitle/legend y=0.975/0.875/
# 0.82/0.775) -- then reapplied to ANY row count by holding these absolute
# inches constant and letting only the axes (bars) portion grow with
# len(order). A fixed *fraction* (the original code) scales the header/
# footer/title area right along with the figure as centers are added,
# which either crushes 6 rows of bars into the same axes height a 3-row
# chart had, or (going the other way) leaves 3 rows swimming in a
# disproportionately tall header margin -- neither is "the chart just got
# taller to fit more bars," which is the only thing that should change.
# 1.2 was tight enough with the capacity tier hidden: show_forecast is now
# unconditionally true (nothing else displays that number once capacity's
# own label is gone), so a row where current and forecast are close (e.g.
# Farmington's jobs: both exactly 5,666) stacks two labels above its bar
# instead of one, reaching higher than 1.2 had budget for -- colliding with
# an off-scale stack on the row above. 1.35 gives that extra line room.
ROW_GAP = 1.35
_CALIBRATION_FIG_HEIGHT_IN = 5.9 + (ROW_GAP - 1) * 1.6 # 6.22, the original 3-row figure height
_CALIBRATION_N = 3
_CALIBRATION_AXES_DATA_RANGE = (_CALIBRATION_N - 1) * ROW_GAP + 1.7
IN_PER_DATA_UNIT = (0.60 * _CALIBRATION_FIG_HEIGHT_IN) / _CALIBRATION_AXES_DATA_RANGE
HEADER_HEIGHT_IN = 0.32 * _CALIBRATION_FIG_HEIGHT_IN
FOOTER_HEIGHT_IN = 0.08 * _CALIBRATION_FIG_HEIGHT_IN
TITLE_TOP_IN = 0.025 * _CALIBRATION_FIG_HEIGHT_IN
SUBTITLE_TOP_IN = 0.125 * _CALIBRATION_FIG_HEIGHT_IN
LEGEND1_TOP_IN = 0.18 * _CALIBRATION_FIG_HEIGHT_IN
LEGEND2_TOP_IN = 0.225 * _CALIBRATION_FIG_HEIGHT_IN
def make_chart(domain_key):
cfg = DOMAIN_CFG[domain_key]
alt = sensitivity[sensitivity["domain"] == cfg["col"]].set_index(["CenterName", "scenario_label"])["capacity_total"]
order = CENTER_ORDER
# modest extra row spacing (vs. a plain unit-spaced chart) so the
# per-scenario tick labels below each bar have room to breathe
row_gap = ROW_GAP
ys = [i * row_gap for i in range(len(order))]
axes_data_range = (len(order) - 1) * row_gap + 1.7
fig_height = HEADER_HEIGHT_IN + IN_PER_DATA_UNIT * axes_data_range + FOOTER_HEIGHT_IN
fig, ax = plt.subplots(figsize=(9.5, fig_height), dpi=140)
fig.patch.set_facecolor(SURFACE)
ax.set_facecolor(SURFACE)
bar_height = 0.5
# axis scale anchors on the largest value actually drawn: capacity
# itself when its tier is shown, but the 2055 forecast (the tallest
# remaining visible tier) once capacity is hidden -- otherwise the axis
# keeps stretching to fit an invisible number, wasting most of the
# chart's width on empty space (most visible for employment, where
# capacity's max of 81,979 dwarfs the 2055 max of 18,110; housing's two
# maxes happen to sit close together -- 7,908 vs. 7,834 -- so it barely
# moves there). The off-scale/clip-and-asterisk handling below doesn't
# change at all -- it just now triggers for more scenario values, which
# it was already built to handle.
base_max = summary[cfg["summary_cap"]].max() if SHOW_CAPACITY_TIER else summary[cfg["summary_2055"]].max()
clip_limit = base_max * 1.3
in_range_alts = [alt[(c, s)] for c in order for s in cfg["alt_scenarios"] if alt[(c, s)] <= clip_limit]
xmax = max([base_max, *in_range_alts])
# off-scale markers/labels anchor a fixed distance PAST xmax (not a
# fraction below it) -- with 6 centers, one center's own borderline
# in-range scenario can land close enough to xmax itself (e.g.
# Farmington's dua_50 at 9,994 against an xmax of 9,994) that an
# edge_x defined as a fraction *below* xmax would collide with that
# legitimate in-range tick's label. Anchoring past xmax instead keeps
# off-scale content clear of every in-range tick, always.
EDGE_X = xmax * 1.05
any_clipped = False
for i, center in enumerate(order):
y = ys[i]
hue = CENTER_COLORS[center]
cap = summary.loc[center, cfg["summary_cap"]]
forecast = summary.loc[center, cfg["summary_2055"]]
current = summary.loc[center, cfg["summary_2025"]]
if SHOW_CAPACITY_TIER:
ax.barh(y, cap, height=bar_height, color=lighten(hue, 0.8), zorder=1)
ax.barh(y, forecast, height=bar_height, facecolor="none", hatch="///", edgecolor=lighten(hue, 0.25), linewidth=0, zorder=2)
ax.barh(y, current, height=bar_height, color=hue, zorder=3)
top_y = y + bar_height / 2
LABEL_Z = 6 # every label out-ranks the bracket (4) and tick (5) lines
# skip forecast's own label only when it would duplicate a VISIBLE
# bold capacity label (The Point: forecast == capacity exactly) --
# with the capacity tier hidden, that number isn't shown anywhere
# else, so forecast's label always earns its place.
show_forecast = not SHOW_CAPACITY_TIER or not np.isclose(forecast, cap, rtol=0.001)
# bump the forecast label above the current label (rather than
# side by side at the same height) when the two values sit close
# enough that their text would otherwise overlap horizontally.
forecast_bump = 0.16 if show_forecast and abs(forecast - current) < xmax * 0.08 else 0.0
if current > 0:
ax.text(current, top_y + 0.07, f"{current:,.0f}", ha="center", va="bottom", fontsize=8.5, color=TEXT_MUTED, zorder=LABEL_Z)
else:
ax.text(current, top_y + 0.07, "0", ha="left", va="bottom", fontsize=8.5, color=TEXT_MUTED, zorder=LABEL_Z)
if show_forecast:
ax.text(forecast, top_y + 0.07 + forecast_bump, f"{forecast:,.0f}", ha="center", va="bottom", fontsize=8.5, color=TEXT_MUTED, zorder=LABEL_Z)
if SHOW_CAPACITY_TIER:
ax.text(cap + xmax * 0.015, y, f"{cap:,.0f}", ha="left", va="center", fontsize=11, color=TEXT_PRIMARY,
fontfamily="Poppins SemiBold", zorder=LABEL_Z,
path_effects=[pe.withStroke(linewidth=3, foreground=SURFACE)])
# --- sensitivity overlay ---
alt_pairs = list(zip(cfg["alt_ticks"], (alt[(center, s)] for s in cfg["alt_scenarios"])))
in_range = [(lab, v) for lab, v in alt_pairs if v <= clip_limit]
clipped = [(lab, v) for lab, v in alt_pairs if v > clip_limit]
tick_top, tick_bot = y + bar_height / 2 + 0.09, y - bar_height / 2 - 0.09
if in_range:
vals = [v for _, v in in_range]
ax.plot([min(vals), max(vals)], [y, y], color=BRACKET_INK, linewidth=1.4, zorder=4, solid_capstyle="round")
# ticks draw at their true value regardless; only the text label
# stacks deeper when two scenario values sit close together.
depths = declutter_depths(vals, min_gap=xmax * 0.08)
for (lab, v), depth in zip(in_range, depths):
sc_color = cfg["scenario_colors"][lab]
ax.plot([v, v], [tick_bot, tick_top], color=sc_color, linewidth=2, zorder=5)
ax.text(v, tick_bot - 0.03 - depth * 0.14, f"{v:,.0f}", ha="center", va="top", fontsize=7.6,
color=sc_color, fontfamily="Poppins SemiBold", zorder=LABEL_Z)
if clipped:
any_clipped = True
ax.plot([EDGE_X], [y], marker=">", markersize=6, color=BRACKET_INK, zorder=5)
# dark-green end-cap tick just past the arrow, in the same
# vertical style as the in-range scenario ticks -- reads as
# "the range keeps going here" rather than a hard stop.
ax.plot([EDGE_X + xmax * 0.006, EDGE_X + xmax * 0.006], [tick_bot, tick_top],
color=OFFSCALE_CAP, linewidth=2, zorder=5)
for idx, (lab, v) in enumerate(sorted(clipped, key=lambda t: t[1])):
sc_color = cfg["scenario_colors"][lab]
ax.text(EDGE_X + xmax * 0.01, tick_bot - 0.03 - idx * 0.15, f"{v:,.0f}*", ha="left", va="top",
fontsize=7.6, color=sc_color, fontfamily="Poppins SemiBold", zorder=LABEL_Z)
ax.set_yticks(ys)
ax.set_yticklabels([CENTER_LABELS[c] for c in order], fontsize=11, color=TEXT_PRIMARY)
ax.set_xlim(0, xmax * 1.18)
ax.set_ylim(-0.75, ys[-1] + 0.95)
ax.get_xaxis().set_visible(False)
for spine in ax.spines.values():
spine.set_visible(False)
ax.tick_params(left=False)
fig.suptitle(cfg["title"], x=0.03, y=1 - TITLE_TOP_IN / fig_height, fontsize=17, color=TEXT_PRIMARY,
ha="left", fontfamily="Poppins SemiBold")
# cfg["subtitle"] stays the full descriptive text (source of truth for
# when SHOW_CAPACITY_TIER is re-enabled); strip the capacity mention
# from what actually renders while the tier is hidden.
subtitle = cfg["subtitle"] if SHOW_CAPACITY_TIER else cfg["subtitle"].replace(", full buildout capacity", "")
fig.text(0.03, 1 - SUBTITLE_TOP_IN / fig_height, subtitle, fontsize=10.5, color=TEXT_MUTED, ha="left")
tier_legend = [
Patch(facecolor=TEXT_MUTED, label="2025 (estimate)"),
Patch(facecolor="none", edgecolor=TEXT_MUTED, hatch="///", label="2055 (forecast)"),
]
if SHOW_CAPACITY_TIER:
tier_legend.append(Patch(facecolor=lighten(TEXT_MUTED, 0.7), label="Full REMM capacity"))
scenario_legend = [
Line2D([0], [0], color=color, marker="|", markersize=10, markeredgewidth=2.2, linestyle="-", linewidth=1.6, label=lab)
for lab, color in cfg["scenario_colors"].items()
]
# Two independently self-centered legend rows (not one grid -- forcing
# jobs's 2 scenario items into a 3-wide grid to match the 3 tier items
# packs them left instead of centering them). Anchor x is 0.5 (true
# figure center), not the axes' own geometric center: the axes reserves
# ~20% of extra width on its right for value labels beyond the bars
# (xlim goes to xmax*1.2) with nothing mirrored on the left, so the bars
# themselves visually center close to figure fraction 0.5.
legend_kwargs = dict(loc="upper center", frameon=False, fontsize=9, handlelength=1.4,
columnspacing=1.3, bbox_transform=fig.transFigure)
legend1 = fig.legend(handles=tier_legend, bbox_to_anchor=(0.5, 1 - LEGEND1_TOP_IN / fig_height), ncol=len(tier_legend),
labelcolor=TEXT_MUTED, **legend_kwargs)
fig.add_artist(legend1)
fig.legend(handles=scenario_legend, bbox_to_anchor=(0.5, 1 - LEGEND2_TOP_IN / fig_height),
ncol=len(scenario_legend), **legend_kwargs)
if any_clipped:
fig.text(0.03, 0.02, "* beyond this chart's scale -- marked > on its row", fontsize=8, color=TEXT_MUTED,
ha="left", style="italic")
fig.text(0.97, 0.02, "Source: REMM 2055 run (WFRC)", fontsize=8, color=TEXT_MUTED, ha="right", style="italic")
top_fraction = 1 - HEADER_HEIGHT_IN / fig_height
bottom_fraction = FOOTER_HEIGHT_IN / fig_height
plt.subplots_adjust(top=top_fraction, bottom=bottom_fraction, left=LEFT, right=RIGHT)
plt.savefig(output_dir / cfg["out"], facecolor=fig.get_facecolor())
plt.close(fig) # saved to disk above; rendered below as a static (lightbox) image, not inline