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

@ -45,7 +45,7 @@
.header .subtitle { opacity: 0.7; font-size: 0.875rem; } .header .subtitle { opacity: 0.7; font-size: 0.875rem; }
.cards { .cards {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 24px;
} }
@ -68,23 +68,26 @@
margin-bottom: 24px; margin-bottom: 24px;
} }
.panel h2 { margin: 0 0 20px; font-size: 1rem; color: #333; } .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; } table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { text-align: left; padding: 10px 12px; border-bottom: 2px solid #e5e7eb; 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; } td { padding: 12px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
.tval { font-weight: 600; font-size: 1rem; } .tval { font-weight: 600; font-size: 1rem; }
.tchg { font-size: 0.75rem; color: #555; margin-top: 2px; } .tchg { font-size: 0.75rem; color: #555; margin-top: 2px; }
.up { color: #c0392b; font-weight: 600; } .up { color: #c0392b; font-weight: 600; }
.down { color: #1e8449; font-weight: 600; } .down { color: #1e8449; font-weight: 600; }
.neutral { color: #888; } .neutral { color: #888; }
.chg-label { background: #eef0f3; border-radius: 3px; padding: 1px 4px; .chg-label { background: #eef0f3; border-radius: 3px; padding: 1px 4px;
font-size: 0.7rem; font-weight: 600; color: #555; margin-right: 2px; } font-size: 0.7rem; font-weight: 600; color: #555; margin-right: 2px; }
.note { font-size: 0.75rem; color: #999; margin-top: 8px; } .note { font-size: 0.75rem; color: #999; margin-top: 10px; }
@media (max-width: 900px) { @media (max-width: 960px) {
.cards { grid-template-columns: repeat(2, 1fr); } .cards { grid-template-columns: repeat(2, 1fr); }
} }
@media (max-width: 600px) {
.cards { grid-template-columns: 1fr; }
}
</style> </style>
</head> </head>
<body> <body>
@ -139,6 +142,26 @@
<div><span class="chg-label">YoY</span> <span class="up">▲ +0.6</span> pp</div> <div><span class="chg-label">YoY</span> <span class="up">▲ +0.6</span> pp</div>
</div> </div>
</div> </div>
<div class="card" style="border-top: 4px solid #d68910">
<div class="card-geo">Baltimore MSA</div>
<div class="card-rate" style="color:#d68910">4.3%</div>
<div class="card-period">April 2026</div>
<div class="card-changes">
<div><span class="chg-label">MoM</span> <span class="neutral">— 0.0</span> pp</div>
<div><span class="chg-label">YoY</span> <span class="up">▲ +0.9</span> pp</div>
</div>
</div>
<div class="card" style="border-top: 4px solid #117a65">
<div class="card-geo">Richmond MSA</div>
<div class="card-rate" style="color:#117a65">3.4%</div>
<div class="card-period">April 2026</div>
<div class="card-changes">
<div><span class="chg-label">MoM</span> <span class="down">▼ -0.3</span> pp</div>
<div><span class="chg-label">YoY</span> <span class="up">▲ +0.4</span> pp</div>
</div>
</div>
</div> </div>
<div class="panel"> <div class="panel">
@ -146,14 +169,22 @@
<div class="chart-wrap"> <div class="chart-wrap">
<canvas id="rateChart"></canvas> <canvas id="rateChart"></canvas>
</div> </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>
<div class="panel"> <div class="panel">
<h2>Current Month Summary</h2> <h2>Current Month Summary</h2>
<table> <table>
<thead><tr><th>Measure</th><th style="color:#c0392b">District of Columbia</th><th style="color:#2471a3">Maryland</th><th style="color:#1e8449">Virginia</th><th style="color:#7d3c98">DC Metro MSA</th></tr></thead> <thead><tr><th>Measure</th><th style="color:#c0392b">District of Columbia</th><th style="color:#2471a3">Maryland</th><th style="color:#1e8449">Virginia</th><th style="color:#7d3c98">DC Metro MSA</th><th style="color:#d68910">Baltimore MSA</th><th style="color:#117a65">Richmond MSA</th></tr></thead>
<tbody><tr><td><strong>Unemp Rate</strong></td><td> <tbody><tr><td><strong>Unemp Rate</strong></td><td>
<div class="tval">5.5%</div> <div class="tval">5.5%</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div> <div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div>
@ -170,6 +201,14 @@
<div class="tval">3.9%</div> <div class="tval">3.9%</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div> <div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.2</span> pp</div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +0.6</span> pp</div> <div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +0.6</span> pp</div>
</td><td>
<div class="tval">4.3%</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="neutral">— 0.0</span> pp</div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +0.9</span> pp</div>
</td><td>
<div class="tval">3.4%</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -0.3</span> pp</div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +0.4</span> pp</div>
</td></tr><tr><td><strong>Unemployed</strong></td><td> </td></tr><tr><td><strong>Unemployed</strong></td><td>
<div class="tval">22,184</div> <div class="tval">22,184</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -694.0</span></div> <div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -694.0</span></div>
@ -186,6 +225,14 @@
<div class="tval">134,887</div> <div class="tval">134,887</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -7625.0</span></div> <div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -7625.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +17530.0</span></div> <div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +17530.0</span></div>
</td><td>
<div class="tval">64,081</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -44.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +13358.0</span></div>
</td><td>
<div class="tval">24,315</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -2538.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="up">▲ +2734.0</span></div>
</td></tr><tr><td><strong>Employed</strong></td><td> </td></tr><tr><td><strong>Employed</strong></td><td>
<div class="tval">379,272</div> <div class="tval">379,272</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -495.0</span></div> <div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -495.0</span></div>
@ -202,6 +249,14 @@
<div class="tval">3,314,544</div> <div class="tval">3,314,544</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="up">▲ +5437.0</span></div> <div class="tchg"><span class="chg-label">MoM</span> <span class="up">▲ +5437.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -100507.0</span></div> <div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -100507.0</span></div>
</td><td>
<div class="tval">1,436,841</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -3081.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -6069.0</span></div>
</td><td>
<div class="tval">691,512</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="up">▲ +1189.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="down">▼ -9943.0</span></div>
</td></tr><tr><td><strong>Labor Force</strong></td><td> </td></tr><tr><td><strong>Labor Force</strong></td><td>
<div class="tval">401,456</div> <div class="tval">401,456</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -1189.0</span></div> <div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -1189.0</span></div>
@ -218,20 +273,26 @@
<div class="tval">3,449,431</div> <div class="tval">3,449,431</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -2188.0</span></div> <div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -2188.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral"></span></div> <div class="tchg"><span class="chg-label">YoY</span> <span class="neutral"></span></div>
</td><td>
<div class="tval">1,500,922</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -3125.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral"></span></div>
</td><td>
<div class="tval">715,827</div>
<div class="tchg"><span class="chg-label">MoM</span> <span class="down">▼ -1349.0</span></div>
<div class="tchg"><span class="chg-label">YoY</span> <span class="neutral"></span></div>
</td></tr></tbody> </td></tr></tbody>
</table> </table>
<p class="note">MoM = month-over-month net change &nbsp;|&nbsp; YoY = year-over-year net change &nbsp;|&nbsp; pp = percentage points</p> <p class="note">MoM = month-over-month net change &nbsp;|&nbsp; YoY = year-over-year net change &nbsp;|&nbsp; pp = percentage points</p>
</div> </div>
<script> <script>
const rateData = {"labels": ["Apr 2024", "May 2024", "Jun 2024", "Jul 2024", "Aug 2024", "Sep 2024", "Oct 2024", "Nov 2024", "Dec 2024", "Jan 2025", "Feb 2025", "Mar 2025", "Apr 2025", "May 2025", "Jun 2025", "Jul 2025", "Aug 2025", "Sep 2025", "Nov 2025", "Dec 2025", "Jan 2026", "Feb 2026", "Mar 2026", "Apr 2026"], "datasets": [{"label": "District of Columbia", "data": [4.5, 5.0, 5.7, 6.0, 6.1, 5.3, 5.2, 5.2, 5.1, 5.8, 6.0, 6.1, 5.5, 5.8, 6.4, 6.8, 7.1, 6.8, 6.8, 6.4, 6.2, 6.1, 5.7, 5.5], "borderColor": "#c0392b", "backgroundColor": "#c0392b22", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Maryland", "data": [2.6, 2.7, 3.5, 3.6, 3.6, 3.1, 3.3, 3.4, 3.0, 3.6, 3.9, 3.8, 3.4, 3.7, 4.2, 4.4, 4.6, 4.3, 4.2, 3.7, 4.6, 4.9, 4.4, 4.4], "borderColor": "#2471a3", "backgroundColor": "#2471a322", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Virginia", "data": [2.5, 2.8, 3.1, 3.2, 3.4, 2.9, 2.9, 3.1, 2.7, 3.2, 3.3, 3.3, 3.0, 3.3, 3.5, 3.5, 3.6, 3.4, 3.9, 3.5, 3.9, 3.9, 3.8, 3.4], "borderColor": "#1e8449", "backgroundColor": "#1e844922", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "DC Metro MSA", "data": [2.6, 2.8, 3.3, 3.4, 3.5, 3.1, 3.2, 3.2, 2.9, 3.4, 3.6, 3.6, 3.3, 3.6, 3.9, 4.1, 4.2, 4.1, 4.3, 3.8, 4.3, 4.5, 4.1, 3.9], "borderColor": "#7d3c98", "backgroundColor": "#7d3c9822", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}]}; const rateData = {"labels": ["Apr 2024", "May 2024", "Jun 2024", "Jul 2024", "Aug 2024", "Sep 2024", "Oct 2024", "Nov 2024", "Dec 2024", "Jan 2025", "Feb 2025", "Mar 2025", "Apr 2025", "May 2025", "Jun 2025", "Jul 2025", "Aug 2025", "Sep 2025", "Nov 2025", "Dec 2025", "Jan 2026", "Feb 2026", "Mar 2026", "Apr 2026"], "datasets": [{"label": "District of Columbia", "data": [4.5, 5.0, 5.7, 6.0, 6.1, 5.3, 5.2, 5.2, 5.1, 5.8, 6.0, 6.1, 5.5, 5.8, 6.4, 6.8, 7.1, 6.8, 6.8, 6.4, 6.2, 6.1, 5.7, 5.5], "borderColor": "#c0392b", "backgroundColor": "#c0392b22", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Maryland", "data": [2.6, 2.7, 3.5, 3.6, 3.6, 3.1, 3.3, 3.4, 3.0, 3.6, 3.9, 3.8, 3.4, 3.7, 4.2, 4.4, 4.6, 4.3, 4.2, 3.7, 4.6, 4.9, 4.4, 4.4], "borderColor": "#2471a3", "backgroundColor": "#2471a322", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Virginia", "data": [2.5, 2.8, 3.1, 3.2, 3.4, 2.9, 2.9, 3.1, 2.7, 3.2, 3.3, 3.3, 3.0, 3.3, 3.5, 3.5, 3.6, 3.4, 3.9, 3.5, 3.9, 3.9, 3.8, 3.4], "borderColor": "#1e8449", "backgroundColor": "#1e844922", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "DC Metro MSA", "data": [2.6, 2.8, 3.3, 3.4, 3.5, 3.1, 3.2, 3.2, 2.9, 3.4, 3.6, 3.6, 3.3, 3.6, 3.9, 4.1, 4.2, 4.1, 4.3, 3.8, 4.3, 4.5, 4.1, 3.9], "borderColor": "#7d3c98", "backgroundColor": "#7d3c9822", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Baltimore MSA", "data": [2.7, 2.8, 3.5, 3.7, 3.7, 3.1, 3.3, 3.4, 3.0, 3.6, 3.9, 3.7, 3.4, 3.7, 4.1, 4.4, 4.6, 4.3, 4.1, 3.6, 4.5, 4.8, 4.3, 4.3], "borderColor": "#d68910", "backgroundColor": "#d6891022", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Richmond MSA", "data": [2.6, 2.9, 3.2, 3.3, 3.5, 3.1, 3.1, 3.2, 2.8, 3.3, 3.3, 3.3, 3.0, 3.2, 3.4, 3.4, 3.5, 3.3, 3.8, 3.4, 3.8, 3.8, 3.7, 3.4], "borderColor": "#117a65", "backgroundColor": "#117a6522", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}]};
const emplData = {"labels": ["Apr 2024", "May 2024", "Jun 2024", "Jul 2024", "Aug 2024", "Sep 2024", "Oct 2024", "Nov 2024", "Dec 2024", "Jan 2025", "Feb 2025", "Mar 2025", "Apr 2025", "May 2025", "Jun 2025", "Jul 2025", "Aug 2025", "Sep 2025", "Nov 2025", "Dec 2025", "Jan 2026", "Feb 2026", "Mar 2026", "Apr 2026"], "datasets": [{"label": "District of Columbia", "data": [393443.0, 388487.0, 391286.0, 395848.0, 386519.0, 389157.0, 390915.0, 391918.0, 391619.0, 390797.0, 393994.0, 391957.0, 389067.0, 384898.0, 389619.0, 389692.0, 378170.0, 378669.0, 381416.0, 382881.0, 377509.0, 380928.0, 379767.0, 379272.0], "borderColor": "#c0392b", "backgroundColor": "#c0392b22", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Maryland", "data": [3143318.0, 3131519.0, 3155007.0, 3178734.0, 3140716.0, 3137737.0, 3142415.0, 3123311.0, 3127985.0, 3103018.0, 3095182.0, 3122412.0, 3119327.0, 3099783.0, 3120745.0, 3132308.0, 3095791.0, 3089859.0, 3063947.0, 3067773.0, 3021249.0, 3044373.0, 3071190.0, 3066322.0], "borderColor": "#2471a3", "backgroundColor": "#2471a322", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Virginia", "data": [4445795.0, 4427688.0, 4448018.0, 4457130.0, 4405504.0, 4410793.0, 4418560.0, 4382390.0, 4389991.0, 4378048.0, 4375837.0, 4402832.0, 4416983.0, 4389680.0, 4403882.0, 4404038.0, 4359467.0, 4357817.0, 4319110.0, 4320551.0, 4304247.0, 4299374.0, 4318506.0, 4333772.0], "borderColor": "#1e8449", "backgroundColor": "#1e844922", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "DC Metro MSA", "data": [3436946.0, 3424474.0, 3448813.0, 3474714.0, 3420626.0, 3418818.0, 3425602.0, 3407429.0, 3412353.0, 3402547.0, 3398633.0, 3410007.0, 3415051.0, 3390834.0, 3412149.0, 3424411.0, 3364937.0, 3354485.0, 3320220.0, 3322050.0, 3286604.0, 3294226.0, 3309107.0, 3314544.0], "borderColor": "#7d3c98", "backgroundColor": "#7d3c9822", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Baltimore MSA", "data": [1451831.0, 1446924.0, 1456421.0, 1467013.0, 1453733.0, 1449952.0, 1454088.0, 1448342.0, 1451430.0, 1438845.0, 1434143.0, 1445000.0, 1442910.0, 1434397.0, 1441441.0, 1445193.0, 1437482.0, 1435013.0, 1429844.0, 1433070.0, 1416307.0, 1428911.0, 1439922.0, 1436841.0], "borderColor": "#d68910", "backgroundColor": "#d6891022", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}, {"label": "Richmond MSA", "data": [701863.0, 702240.0, 706580.0, 710358.0, 699383.0, 696886.0, 699164.0, 694222.0, 696686.0, 698143.0, 695270.0, 698984.0, 701455.0, 699723.0, 704918.0, 708022.0, 699445.0, 697769.0, 691306.0, 692163.0, 690372.0, 687296.0, 690323.0, 691512.0], "borderColor": "#117a65", "backgroundColor": "#117a6522", "borderWidth": 2, "pointRadius": 2, "tension": 0.3, "fill": false}]};
new Chart(document.getElementById("rateChart"), { new Chart(document.getElementById("rateChart"), {
type: "line", type: "line",
data: { data: { labels: rateData.labels, datasets: rateData.datasets },
labels: rateData.labels,
datasets: rateData.datasets,
},
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@ -250,10 +311,33 @@ new Chart(document.getElementById("rateChart"), {
ticks: { callback: v => v + "%" }, ticks: { callback: v => v + "%" },
grid: { color: "#f0f0f0" }, grid: { color: "#f0f0f0" },
}, },
x: { x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } }
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 } }
} }
} }
}); });

View File

@ -1,7 +1,7 @@
""" """
DC/MD/VA Unemployment — Static HTML Report Generator DC/MD/VA Unemployment — Static HTML Report Generator
Run: python3 generate_report.py 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 import requests
@ -13,15 +13,17 @@ from config import BLS_API_KEY, BLS_API_BASE
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) 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 = { LABELS = {
"DC": "District of Columbia", "DC": "District of Columbia",
"MD": "Maryland", "MD": "Maryland",
"VA": "Virginia", "VA": "Virginia",
"DCMSA": "DC Metro MSA", "DCMSA": "DC Metro MSA",
"BAL": "Baltimore MSA",
"RIC": "Richmond MSA",
} }
COLORS = { COLORS = {
@ -29,25 +31,41 @@ COLORS = {
"MD": "#2471a3", "MD": "#2471a3",
"VA": "#1e8449", "VA": "#1e8449",
"DCMSA": "#7d3c98", "DCMSA": "#7d3c98",
"BAL": "#d68910",
"RIC": "#117a65",
} }
SERIES = { SERIES = {
# District of Columbia (state FIPS 11)
"DC_rate": "LAUST110000000000003", "DC_rate": "LAUST110000000000003",
"DC_unemployed": "LAUST110000000000004", "DC_unemployed": "LAUST110000000000004",
"DC_employed": "LAUST110000000000005", "DC_employed": "LAUST110000000000005",
"DC_laborforce": "LAUST110000000000006", "DC_laborforce": "LAUST110000000000006",
# Maryland (state FIPS 24)
"MD_rate": "LAUST240000000000003", "MD_rate": "LAUST240000000000003",
"MD_unemployed": "LAUST240000000000004", "MD_unemployed": "LAUST240000000000004",
"MD_employed": "LAUST240000000000005", "MD_employed": "LAUST240000000000005",
"MD_laborforce": "LAUST240000000000006", "MD_laborforce": "LAUST240000000000006",
# Virginia (state FIPS 51)
"VA_rate": "LAUST510000000000003", "VA_rate": "LAUST510000000000003",
"VA_unemployed": "LAUST510000000000004", "VA_unemployed": "LAUST510000000000004",
"VA_employed": "LAUST510000000000005", "VA_employed": "LAUST510000000000005",
"VA_laborforce": "LAUST510000000000006", "VA_laborforce": "LAUST510000000000006",
# DC-Arlington-Alexandria MSA (state 11, CBSA 47900)
"DCMSA_rate": "LAUMT114790000000003", "DCMSA_rate": "LAUMT114790000000003",
"DCMSA_unemployed":"LAUMT114790000000004", "DCMSA_unemployed":"LAUMT114790000000004",
"DCMSA_employed": "LAUMT114790000000005", "DCMSA_employed": "LAUMT114790000000005",
"DCMSA_laborforce":"LAUMT114790000000006", "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 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_ORDER = {f"M{i:02d}": i for i in range(1, 13)}
MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
@ -101,37 +112,33 @@ MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
def sort_obs(data): 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"] != "-"] 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))) return sorted(monthly, key=lambda o: (o["year"], MONTH_ORDER.get(o["period"], 0)))
def build_rate_chart_data(results, months=24): def build_chart_data(results, measure, months=24):
"""Return labels list and per-geo value lists for the rate trend chart.""" """Build Chart.js labels + datasets for any measure (rate, employed, etc.)."""
# Build a merged label set from all four geos
all_labels = {} all_labels = {}
for geo in GEOS: for geo in GEOS:
sid = SERIES[f"{geo}_rate"] sid = SERIES[f"{geo}_{measure}"]
for obs in sort_obs(results.get(sid, [])): for obs in sort_obs(results.get(sid, [])):
key = (obs["year"], obs["period"]) key = (obs["year"], obs["period"])
m = MONTH_ORDER.get(obs["period"], 0) m = MONTH_ORDER.get(obs["period"], 0)
label = f"{MONTH_NAMES[m]} {obs['year']}" all_labels[key] = f"{MONTH_NAMES[m]} {obs['year']}"
all_labels[key] = label
sorted_keys = sorted(all_labels.keys())[-months:] sorted_keys = sorted(all_labels.keys())[-months:]
labels = [all_labels[k] for k in sorted_keys] labels = [all_labels[k] for k in sorted_keys]
datasets = [] datasets = []
for geo in GEOS: for geo in GEOS:
sid = SERIES[f"{geo}_rate"] sid = SERIES[f"{geo}_{measure}"]
obs_by_key = { obs_by_key = {
(o["year"], o["period"]): float(o["value"]) (o["year"], o["period"]): float(o["value"])
for o in sort_obs(results.get(sid, [])) for o in sort_obs(results.get(sid, []))
} }
values = [obs_by_key.get(k) for k in sorted_keys]
datasets.append({ datasets.append({
"label": LABELS[geo], "label": LABELS[geo],
"data": values, "data": [obs_by_key.get(k) for k in sorted_keys],
"borderColor": COLORS[geo], "borderColor": COLORS[geo],
"backgroundColor": COLORS[geo] + "22", "backgroundColor": COLORS[geo] + "22",
"borderWidth": 2, "borderWidth": 2,
@ -199,10 +206,10 @@ def build_cards(results):
def build_summary_table(results): def build_summary_table(results):
measures = [ measures = [
("rate", "Unemp Rate", True), ("rate", "Unemp Rate", True),
("unemployed", "Unemployed", False), ("unemployed", "Unemployed", False),
("employed", "Employed", False), ("employed", "Employed", False),
("laborforce", "Labor Force", False), ("laborforce", "Labor Force", False),
] ]
rows = [] rows = []
for measure, label, is_rate in measures: for measure, label, is_rate in measures:
@ -211,15 +218,11 @@ def build_summary_table(results):
sid = SERIES[f"{geo}_{measure}"] sid = SERIES[f"{geo}_{measure}"]
obs = latest(results.get(sid, [])) obs = latest(results.get(sid, []))
val = fmt_rate(obs.get("value")) if is_rate else fmt_num(obs.get("value")) 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 "" suffix = "pp" if is_rate else ""
mom_html = arrow(mom)
yoy_html = arrow(yoy)
row += f"""<td> row += f"""<td>
<div class="tval">{val}</div> <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">MoM</span> {arrow(chg(obs,"1"))}{' ' + 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">YoY</span> {arrow(chg(obs,"12"))}{' ' + suffix if suffix else ''}</div>
</td>""" </td>"""
row += "</tr>" row += "</tr>"
rows.append(row) rows.append(row)
@ -232,7 +235,7 @@ def build_summary_table(results):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main HTML template # HTML template
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
HTML_TEMPLATE = """<!DOCTYPE html> HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -261,7 +264,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
.header .subtitle {{ opacity: 0.7; font-size: 0.875rem; }} .header .subtitle {{ opacity: 0.7; font-size: 0.875rem; }}
.cards {{ .cards {{
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 24px;
}} }}
@ -284,23 +287,26 @@ HTML_TEMPLATE = """<!DOCTYPE html>
margin-bottom: 24px; margin-bottom: 24px;
}} }}
.panel h2 {{ margin: 0 0 20px; font-size: 1rem; color: #333; }} .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; }} table {{ width: 100%; border-collapse: collapse; font-size: 0.875rem; }}
th {{ text-align: left; padding: 10px 12px; border-bottom: 2px solid #e5e7eb; 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; }} td {{ padding: 12px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
tr:last-child td {{ border-bottom: none; }} tr:last-child td {{ border-bottom: none; }}
.tval {{ font-weight: 600; font-size: 1rem; }} .tval {{ font-weight: 600; font-size: 1rem; }}
.tchg {{ font-size: 0.75rem; color: #555; margin-top: 2px; }} .tchg {{ font-size: 0.75rem; color: #555; margin-top: 2px; }}
.up {{ color: #c0392b; font-weight: 600; }} .up {{ color: #c0392b; font-weight: 600; }}
.down {{ color: #1e8449; font-weight: 600; }} .down {{ color: #1e8449; font-weight: 600; }}
.neutral {{ color: #888; }} .neutral {{ color: #888; }}
.chg-label {{ background: #eef0f3; border-radius: 3px; padding: 1px 4px; .chg-label {{ background: #eef0f3; border-radius: 3px; padding: 1px 4px;
font-size: 0.7rem; font-weight: 600; color: #555; margin-right: 2px; }} font-size: 0.7rem; font-weight: 600; color: #555; margin-right: 2px; }}
.note {{ font-size: 0.75rem; color: #999; margin-top: 8px; }} .note {{ font-size: 0.75rem; color: #999; margin-top: 10px; }}
@media (max-width: 900px) {{ @media (max-width: 960px) {{
.cards {{ grid-template-columns: repeat(2, 1fr); }} .cards {{ grid-template-columns: repeat(2, 1fr); }}
}} }}
@media (max-width: 600px) {{
.cards {{ grid-template-columns: 1fr; }}
}}
</style> </style>
</head> </head>
<body> <body>
@ -323,7 +329,15 @@ HTML_TEMPLATE = """<!DOCTYPE html>
<div class="chart-wrap"> <div class="chart-wrap">
<canvas id="rateChart"></canvas> <canvas id="rateChart"></canvas>
</div> </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>
<div class="panel"> <div class="panel">
@ -334,13 +348,11 @@ HTML_TEMPLATE = """<!DOCTYPE html>
<script> <script>
const rateData = {rate_chart_json}; const rateData = {rate_chart_json};
const emplData = {empl_chart_json};
new Chart(document.getElementById("rateChart"), {{ new Chart(document.getElementById("rateChart"), {{
type: "line", type: "line",
data: {{ data: {{ labels: rateData.labels, datasets: rateData.datasets }},
labels: rateData.labels,
datasets: rateData.datasets,
}},
options: {{ options: {{
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@ -359,10 +371,33 @@ new Chart(document.getElementById("rateChart"), {{
ticks: {{ callback: v => v + "%" }}, ticks: {{ callback: v => v + "%" }},
grid: {{ color: "#f0f0f0" }}, grid: {{ color: "#f0f0f0" }},
}}, }},
x: {{ x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 12 }} }}
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(): def main():
current_year = datetime.now().year 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})...") print(f"Fetching {len(SERIES)} LAUS series ({start_year}{current_year})...")
results = fetch(list(SERIES.values()), 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: with open(chartjs_path) as f:
chartjs = f.read() chartjs = f.read()
labels, datasets = build_rate_chart_data(results, months=24) rate_labels, rate_datasets = build_chart_data(results, "rate", months=24)
rate_chart_json = json.dumps({"labels": labels, "datasets": datasets}) empl_labels, empl_datasets = build_chart_data(results, "employed", months=24)
html = HTML_TEMPLATE.format( html = HTML_TEMPLATE.format(
report_date=report_date, report_date=report_date,
cards=build_cards(results), cards=build_cards(results),
summary_table=build_summary_table(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, chartjs=chartjs,
) )