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:
@ -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 | ▼ Green = rate falling | 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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user