Add Baltimore/Richmond MSAs and employment level chart

- Added Baltimore-Columbia-Towson MSA (CBSA 12580) and Richmond MSA (CBSA 40060)
- Now covers 6 geographies: DC, MD, VA states + DC/Baltimore/Richmond MSAs
- Added second Chart.js line chart showing employment levels over 24 months
- Cards grid updated to 3-column layout (2 rows of 3)
- Y-axis auto-formats to K/M for employment chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:43:25 -04:00
parent ae46e429a8
commit 0242977abe
2 changed files with 188 additions and 68 deletions

View File

@ -1,7 +1,7 @@
"""
DC/MD/VA Unemployment — Static HTML Report Generator
Run: python3 generate_report.py
Output: dc_md_va_unemployment_report.html (open in any browser)
Output: dc_md_va_unemployment_report.html (open in any browser, no internet required)
"""
import requests
@ -13,15 +13,17 @@ from config import BLS_API_KEY, BLS_API_BASE
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------------------
# Series config (same as dashboard script)
# Series config
# ---------------------------------------------------------------------------
GEOS = ["DC", "MD", "VA", "DCMSA"]
GEOS = ["DC", "MD", "VA", "DCMSA", "BAL", "RIC"]
LABELS = {
"DC": "District of Columbia",
"MD": "Maryland",
"VA": "Virginia",
"DCMSA": "DC Metro MSA",
"BAL": "Baltimore MSA",
"RIC": "Richmond MSA",
}
COLORS = {
@ -29,25 +31,41 @@ COLORS = {
"MD": "#2471a3",
"VA": "#1e8449",
"DCMSA": "#7d3c98",
"BAL": "#d68910",
"RIC": "#117a65",
}
SERIES = {
# District of Columbia (state FIPS 11)
"DC_rate": "LAUST110000000000003",
"DC_unemployed": "LAUST110000000000004",
"DC_employed": "LAUST110000000000005",
"DC_laborforce": "LAUST110000000000006",
# Maryland (state FIPS 24)
"MD_rate": "LAUST240000000000003",
"MD_unemployed": "LAUST240000000000004",
"MD_employed": "LAUST240000000000005",
"MD_laborforce": "LAUST240000000000006",
# Virginia (state FIPS 51)
"VA_rate": "LAUST510000000000003",
"VA_unemployed": "LAUST510000000000004",
"VA_employed": "LAUST510000000000005",
"VA_laborforce": "LAUST510000000000006",
# DC-Arlington-Alexandria MSA (state 11, CBSA 47900)
"DCMSA_rate": "LAUMT114790000000003",
"DCMSA_unemployed":"LAUMT114790000000004",
"DCMSA_employed": "LAUMT114790000000005",
"DCMSA_laborforce":"LAUMT114790000000006",
# Baltimore-Columbia-Towson MSA (state 24, CBSA 12580)
"BAL_rate": "LAUMT241258000000003",
"BAL_unemployed": "LAUMT241258000000004",
"BAL_employed": "LAUMT241258000000005",
"BAL_laborforce": "LAUMT241258000000006",
# Richmond MSA (state 51, CBSA 40060)
"RIC_rate": "LAUMT514006000000003",
"RIC_unemployed": "LAUMT514006000000004",
"RIC_employed": "LAUMT514006000000005",
"RIC_laborforce": "LAUMT514006000000006",
}
# ---------------------------------------------------------------------------
@ -85,15 +103,8 @@ def chg(obs, window):
return None
def pct_chg(obs, window):
try:
return obs["calculations"]["pct_changes"].get(window)
except (KeyError, TypeError):
return None
# ---------------------------------------------------------------------------
# Prepare chart data — chronological, last 24 months, no gaps
# Chart data builders
# ---------------------------------------------------------------------------
MONTH_ORDER = {f"M{i:02d}": i for i in range(1, 13)}
MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
@ -101,37 +112,33 @@ MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
def sort_obs(data):
"""Return observations sorted chronologically, monthly only, no nulls."""
monthly = [o for o in data if o["period"].startswith("M") and o["value"] != "-"]
return sorted(monthly, key=lambda o: (o["year"], MONTH_ORDER.get(o["period"], 0)))
def build_rate_chart_data(results, months=24):
"""Return labels list and per-geo value lists for the rate trend chart."""
# Build a merged label set from all four geos
def build_chart_data(results, measure, months=24):
"""Build Chart.js labels + datasets for any measure (rate, employed, etc.)."""
all_labels = {}
for geo in GEOS:
sid = SERIES[f"{geo}_rate"]
sid = SERIES[f"{geo}_{measure}"]
for obs in sort_obs(results.get(sid, [])):
key = (obs["year"], obs["period"])
m = MONTH_ORDER.get(obs["period"], 0)
label = f"{MONTH_NAMES[m]} {obs['year']}"
all_labels[key] = label
all_labels[key] = f"{MONTH_NAMES[m]} {obs['year']}"
sorted_keys = sorted(all_labels.keys())[-months:]
labels = [all_labels[k] for k in sorted_keys]
datasets = []
for geo in GEOS:
sid = SERIES[f"{geo}_rate"]
sid = SERIES[f"{geo}_{measure}"]
obs_by_key = {
(o["year"], o["period"]): float(o["value"])
for o in sort_obs(results.get(sid, []))
}
values = [obs_by_key.get(k) for k in sorted_keys]
datasets.append({
"label": LABELS[geo],
"data": values,
"data": [obs_by_key.get(k) for k in sorted_keys],
"borderColor": COLORS[geo],
"backgroundColor": COLORS[geo] + "22",
"borderWidth": 2,
@ -199,10 +206,10 @@ def build_cards(results):
def build_summary_table(results):
measures = [
("rate", "Unemp Rate", True),
("unemployed", "Unemployed", False),
("employed", "Employed", False),
("laborforce", "Labor Force", False),
("rate", "Unemp Rate", True),
("unemployed", "Unemployed", False),
("employed", "Employed", False),
("laborforce", "Labor Force", False),
]
rows = []
for measure, label, is_rate in measures:
@ -211,15 +218,11 @@ def build_summary_table(results):
sid = SERIES[f"{geo}_{measure}"]
obs = latest(results.get(sid, []))
val = fmt_rate(obs.get("value")) if is_rate else fmt_num(obs.get("value"))
mom = chg(obs, "1")
yoy = chg(obs, "12")
suffix = "pp" if is_rate else ""
mom_html = arrow(mom)
yoy_html = arrow(yoy)
row += f"""<td>
<div class="tval">{val}</div>
<div class="tchg"><span class="chg-label">MoM</span> {mom_html}{' ' + suffix if suffix else ''}</div>
<div class="tchg"><span class="chg-label">YoY</span> {yoy_html}{' ' + suffix if suffix else ''}</div>
<div class="tchg"><span class="chg-label">MoM</span> {arrow(chg(obs,"1"))}{' ' + suffix if suffix else ''}</div>
<div class="tchg"><span class="chg-label">YoY</span> {arrow(chg(obs,"12"))}{' ' + suffix if suffix else ''}</div>
</td>"""
row += "</tr>"
rows.append(row)
@ -232,7 +235,7 @@ def build_summary_table(results):
# ---------------------------------------------------------------------------
# Main HTML template
# HTML template
# ---------------------------------------------------------------------------
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
@ -261,7 +264,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
.header .subtitle {{ opacity: 0.7; font-size: 0.875rem; }}
.cards {{
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}}
@ -284,23 +287,26 @@ HTML_TEMPLATE = """<!DOCTYPE html>
margin-bottom: 24px;
}}
.panel h2 {{ margin: 0 0 20px; font-size: 1rem; color: #333; }}
.chart-wrap {{ position: relative; height: 320px; }}
.chart-wrap {{ position: relative; height: 340px; }}
table {{ width: 100%; border-collapse: collapse; font-size: 0.875rem; }}
th {{ text-align: left; padding: 10px 12px; border-bottom: 2px solid #e5e7eb;
font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; }}
font-size: 0.75rem; text-transform: uppercase; letter-spacing: .04em; }}
td {{ padding: 12px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
tr:last-child td {{ border-bottom: none; }}
.tval {{ font-weight: 600; font-size: 1rem; }}
.tchg {{ font-size: 0.75rem; color: #555; margin-top: 2px; }}
.up {{ color: #c0392b; font-weight: 600; }}
.down {{ color: #1e8449; font-weight: 600; }}
.up {{ color: #c0392b; font-weight: 600; }}
.down {{ color: #1e8449; font-weight: 600; }}
.neutral {{ color: #888; }}
.chg-label {{ background: #eef0f3; border-radius: 3px; padding: 1px 4px;
font-size: 0.7rem; font-weight: 600; color: #555; margin-right: 2px; }}
.note {{ font-size: 0.75rem; color: #999; margin-top: 8px; }}
@media (max-width: 900px) {{
.note {{ font-size: 0.75rem; color: #999; margin-top: 10px; }}
@media (max-width: 960px) {{
.cards {{ grid-template-columns: repeat(2, 1fr); }}
}}
@media (max-width: 600px) {{
.cards {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
@ -323,7 +329,15 @@ HTML_TEMPLATE = """<!DOCTYPE html>
<div class="chart-wrap">
<canvas id="rateChart"></canvas>
</div>
<p class="note">▲ Red = rate rising (more unemployment). ▼ Green = rate falling.</p>
<p class="note">▲ Red = rate rising &nbsp;|&nbsp; ▼ Green = rate falling &nbsp;|&nbsp; Not seasonally adjusted</p>
</div>
<div class="panel">
<h2>Employment Level Trend — Last 24 Months</h2>
<div class="chart-wrap">
<canvas id="emplChart"></canvas>
</div>
<p class="note">State totals (DC/MD/VA) are significantly larger than MSA figures. Hover to compare values.</p>
</div>
<div class="panel">
@ -334,13 +348,11 @@ HTML_TEMPLATE = """<!DOCTYPE html>
<script>
const rateData = {rate_chart_json};
const emplData = {empl_chart_json};
new Chart(document.getElementById("rateChart"), {{
type: "line",
data: {{
labels: rateData.labels,
datasets: rateData.datasets,
}},
data: {{ labels: rateData.labels, datasets: rateData.datasets }},
options: {{
responsive: true,
maintainAspectRatio: false,
@ -359,10 +371,33 @@ new Chart(document.getElementById("rateChart"), {{
ticks: {{ callback: v => v + "%" }},
grid: {{ color: "#f0f0f0" }},
}},
x: {{
grid: {{ display: false }},
ticks: {{ maxTicksLimit: 12 }},
x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 12 }} }}
}}
}}
}});
new Chart(document.getElementById("emplChart"), {{
type: "line",
data: {{ labels: emplData.labels, datasets: emplData.datasets }},
options: {{
responsive: true,
maintainAspectRatio: false,
interaction: {{ mode: "index", intersect: false }},
plugins: {{
legend: {{ position: "top", labels: {{ usePointStyle: true, padding: 16 }} }},
tooltip: {{
callbacks: {{
label: ctx => ` ${{ctx.dataset.label}}: ${{ctx.parsed.y !== null ? ctx.parsed.y.toLocaleString() : "N/A"}}`
}}
}}
}},
scales: {{
y: {{
title: {{ display: true, text: "Employed Persons" }},
ticks: {{ callback: v => (v >= 1000000 ? (v/1000000).toFixed(1)+"M" : (v/1000).toFixed(0)+"K") }},
grid: {{ color: "#f0f0f0" }},
}},
x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 12 }} }}
}}
}}
}});
@ -378,7 +413,7 @@ new Chart(document.getElementById("rateChart"), {{
# ---------------------------------------------------------------------------
def main():
current_year = datetime.now().year
start_year = current_year - 2 # 3 years covers 24+ months for the chart
start_year = current_year - 2
print(f"Fetching {len(SERIES)} LAUS series ({start_year}{current_year})...")
results = fetch(list(SERIES.values()), start_year, current_year)
@ -389,14 +424,15 @@ def main():
with open(chartjs_path) as f:
chartjs = f.read()
labels, datasets = build_rate_chart_data(results, months=24)
rate_chart_json = json.dumps({"labels": labels, "datasets": datasets})
rate_labels, rate_datasets = build_chart_data(results, "rate", months=24)
empl_labels, empl_datasets = build_chart_data(results, "employed", months=24)
html = HTML_TEMPLATE.format(
report_date=report_date,
cards=build_cards(results),
summary_table=build_summary_table(results),
rate_chart_json=rate_chart_json,
rate_chart_json=json.dumps({"labels": rate_labels, "datasets": rate_datasets}),
empl_chart_json=json.dumps({"labels": empl_labels, "datasets": empl_datasets}),
chartjs=chartjs,
)