FUNC = "/.netlify/functions/geo"
Plot = require("@observablehq/plot@0.6")
mode = {
try {
// The function answers the liveness probe with 200; a static host 404s.
const r = await fetch(`${FUNC}?id=ping`);
if (r.ok) return "function";
} catch (e) {}
return "static";
}
index = (await fetch(mode === "function" ? `${FUNC}?id=index` : "./geo_index.json")).json()
meta = index.meta
bundleCache = new Map()
async function loadGeo(id) {
// Revalidate per-geo metrics on every load (cache:"no-cache") so a freshly
// added metric or the weekly data refresh shows without a hard refresh; the
// payload is small and the function sets a short edge cache.
if (mode === "function") return (await fetch(`${FUNC}?id=${id}`, {cache: "no-cache"})).json();
// Static fallback: 7-digit places live in a separate per-state bundle.
const file = id.length === 7 ? `${id.slice(0, 2)}_places.json` : `${id.slice(0, 2)}.json`;
if (!bundleCache.has(file)) bundleCache.set(file, fetch(`./states/${file}`).then(r => r.json()));
return (await bundleCache.get(file))[id];
}
COLORS = ["#003270", "#0b6cc4", "#5fb3ff", "#f4a300", "#c43c00", "#6d6d6d"]
// Income brackets are ordered (low -> high income), so a light -> dark blue ramp.
INCOME_COLORS = ["#9ecae1", "#6baed6", "#4292c6", "#2171b5", "#08519c", "#08306b"]
// Race/ethnicity is categorical: four muted, deliberately arbitrary hues
// (blue/red/green/purple) chosen to avoid any color that could be read as coding
// the category (none is white, black, yellow, or brown).
RACE_COLORS = ["#3d6fb0", "#c1554e", "#4e9e6a", "#8a66ad"]
// Responsive chart width: half the content column minus the row gap (16) and the
// two cards' chrome (~60), so two charts fill each row and reflow on resize.
// `width` is Quarto OJS's reactive column width. Clamped so charts neither get
// cramped nor balloon on ultrawide screens.
chartW = Math.max(500, Math.min(820, Math.floor((width - 76) / 2)))Broadband Data by Geography
Pick a state, county or place for a broadband snapshot
stateRec = index.states.find(s => s.id === stateId)
viewof countyId = Inputs.select(
// Nationwide has no sub-areas: show a neutral "— area —" placeholder (still
// valued "US" so the snapshot stays national) instead of a redundant
// "United States" twin of the Nationwide state option.
new Map([
[stateRec.id === "US" ? "— area —" : "— county —", stateRec.id],
...stateRec.counties.map(c => [c.name, c.id]),
]),
{label: stateRec.id === "US" ? "Area" : "County",
disabled: stateRec.counties.length === 0}
)// Place selector, filtered by state (places can span counties). Referencing
// countyId makes this reset when the county changes; a selected place overrides
// the county. "— place —" means "use the county/state selection".
viewof placeId = {
// When a specific county (5-digit id) is selected, narrow places to those with
// homes in it; a whole-state or nationwide selection shows every place in the
// state. Multi-county places list each county they touch, so they appear under
// any of them.
const all = stateRec.places || [];
const places = (countyId && countyId.length === 5)
? all.filter(p => (p.cty || []).includes(countyId))
: all;
return Inputs.select(
new Map([["— place —", ""], ...places.map(p => [p.name, p.id])]),
{label: "Place", disabled: places.length === 0}
)
}pct = x => x == null ? "—" : (x * 100).toFixed(1) + "%"
fmtSpeed = m => m == null ? "—" : (m >= 1000 ? (m / 1000).toFixed(1) + " Gbps" : Math.round(m) + " Mbps")
fmtInt = x => x == null ? "—" : x.toLocaleString("en-US")
fmtMoney = x => x == null ? "—"
: x >= 1e9 ? "$" + (x / 1e9).toFixed(1) + "B"
: x >= 1e6 ? "$" + (x / 1e6).toFixed(1) + "M"
: x >= 1e3 ? "$" + (x / 1e3).toFixed(0) + "k" : "$" + Math.round(x)
// Compact count for axis ticks: 30K, 1.5M, 2B (trailing .0 dropped).
fmtNum = n => n == null ? ""
: Math.abs(n) >= 1e9 ? +(n / 1e9).toFixed(1) + "B"
: Math.abs(n) >= 1e6 ? +(n / 1e6).toFixed(1) + "M"
: Math.abs(n) >= 1e3 ? +(n / 1e3).toFixed(1) + "K" : String(n)
function valueBox(label, value, accent) {
return html`<div style="flex:1;min-width:160px;border:1px solid #e3e3e3;border-left:4px solid ${accent};
border-radius:8px;padding:14px 16px;background:#fff">
<div style="font-size:1.7em;font-weight:700;color:#003270">${value}</div>
<div style="font-size:.8em;color:#666;margin-top:2px">${label}</div>
</div>`
}
// One inline stat (big value + small label) for a card.
function miniStat(label, value) {
return html`<div><span style="font-size:1.3em;font-weight:700;color:#003270">${value}</span>
<span style="font-size:.82em;color:#666;margin-left:6px">${label}</span></div>`
}
// The "Gig" tier is really a 900/20 threshold; label it like the other tiers.
gigLabel = `${meta.params.gig_down}/${meta.params.gig_up}`
// Rename series keys in a {dates, series} trend object (order preserved).
function relabel(trend, map) {
if (!trend) return trend
const series = {}
for (const [k, v] of Object.entries(trend.series)) series[map[k] || k] = v
return {dates: trend.dates, series}
}
// Long-form points for a {dates, series} trend object, as % values.
function trendPoints(trend) {
if (!trend) return [];
return Object.entries(trend.series).flatMap(([label, vals]) =>
trend.dates.map((d, i) => ({date: d, label, value: vals[i]}))
.filter(p => p.value != null)
)
}
// Card wrapper: a small caption + the plot node, matching the value-box style.
function card(title, node) {
return html`<div style="border:1px solid #e3e3e3;border-radius:8px;padding:12px 14px;background:#fff">
<div style="font-size:.88em;font-weight:700;color:#1a1a1a;margin-bottom:4px">${title}</div>
${node}
</div>`
}
// Section heading.
function section(title) {
return html`<h3 style="color:#003270;border-bottom:2px solid #e3e3e3;padding-bottom:4px;
margin:28px 0 12px;font-size:1.15em">${title}</h3>`
}
// A flex-wrap row of chart cards.
function chartRow(...children) {
return html`<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start">${children}</div>`
}
// Compact label/percent table, for the 5-Year county snapshot (no trend).
function pctTable(title, dist) {
if (!dist) return ""
const rows = dist.labels.map((l, i) => html`<tr>
<td style="padding:3px 16px 3px 0;color:#444">${l}</td>
<td style="text-align:right;font-weight:600;color:#003270">${pct(dist.values[i])}</td></tr>`)
return card(title, html`<table style="border-collapse:collapse;font-size:.92em;margin-top:2px">${rows}</table>`)
}
// Technology availability table: % of units with each tech available at any
// speed, with the unit count on hover. Values are counts; divide by total.
function techTable(title, dist, total) {
if (!dist || !total) return ""
const rows = dist.labels.map((l, i) => {
const u = dist.values[i]
return html`<tr title="${fmtInt(u)} of ${fmtInt(total)} residential units">
<td style="padding:3px 16px 3px 0;color:#444">${l}</td>
<td style="text-align:right;font-weight:600;color:#003270">${pct(u / total)}</td></tr>`
})
return card(title, html`<table style="border-collapse:collapse;font-size:.92em;margin-top:2px">${rows}</table>`)
}
// Section heading + its cards, rendered only when at least one card is present,
// so a section with no data (e.g. Funding for a county with no BEAD) shows
// nothing rather than a bare heading. Missing cards are passed as "".
function sectionBlock(title, cards) {
const real = cards.filter(c => c)
return real.length ? html`${section(title)}${chartRow(...real)}` : ""
}
// Conservative auto y-domain: pad the data range, snap to 0.05, clamp to
// [0,1], and never zoom tighter than a 0.3 span (so ~99% lines keep context).
function niceDomain(values, {minSpan = 0.15, lo = 0, hi = 1, step = 0.05} = {}) {
const vmin = Math.min(...values), vmax = Math.max(...values)
const pad = Math.max(0.02, (vmax - vmin) * 0.15)
let d0 = Math.max(lo, Math.floor((vmin - pad) / step) * step)
let d1 = Math.min(hi, Math.ceil((vmax + pad) / step) * step)
if (d1 - d0 < minSpan) {
const mid = (d0 + d1) / 2
d0 = mid - minSpan / 2; d1 = mid + minSpan / 2
if (d0 < lo) { d1 += lo - d0; d0 = lo }
if (d1 > hi) { d0 -= d1 - hi; d1 = hi }
d0 = Math.max(lo, d0); d1 = Math.min(hi, d1)
}
return [d0, d1]
}
// Short labels for the value strip (which doubles as the legend). Anything not
// listed is used as-is (already short, e.g. "25/3", "100/20", income brackets).
STRIP_ABBREV = ({
"5G NR (7/1)": "5G 7/1",
"5G NR (35/3)": "5G 35/3",
"Wired only": "Wired",
"Wired + Licensed FWA": "Wired+FWA",
"Any broadband": "Any",
"Wireline broadband": "Wireline",
"White (non-Hisp.)": "White",
})
function trendChart(trend, {title, minDate, yLabel = "% of units", colors = COLORS, width = chartW}) {
let data = trendPoints(trend)
if (minDate) data = data.filter(p => p.date >= minDate)
if (!data.length) return card(title, html`<div style="color:#999">No data.</div>`)
// Yearly ACS series use a numeric x so the suppressed 2020 still shows on the
// axis (no dot) and the line connects 2019->2021 across the gap. BDC series
// (ISO dates) stay on an ordinal point scale.
const yearly = /^\d{4}$/.test(data[0].date)
const pdata = data.map(p => ({...p, x: yearly ? +p.date : p.date}))
// Explicit y ticks across the (nice) domain so there's always a tick + grid
// line at the bottom and top edges, not a floating partial axis.
const ydom = niceDomain(data.map(d => d.value))
const span = ydom[1] - ydom[0]
const ystep = span <= 0.2 ? 0.05 : span <= 0.5 ? 0.1 : 0.2
const yticks = []
for (let t = ydom[0]; t <= ydom[1] + 1e-9; t += ystep) yticks.push(+t.toFixed(2))
// Series (in color/legend order), their chart colors, and short row labels.
const series = [...new Set(data.map(d => d.label))]
const abbr = l => STRIP_ABBREV[l] || l
const colorOf = l => colors[series.indexOf(l) % colors.length]
// Left margin shared by the chart and the strip, widened to fit the longest
// row label (at the strip font, ~0.6em/char) so the value columns still line
// up under the chart's ticks. Height scales with width to keep the aspect.
const ML = Math.max(52, Math.ceil(Math.max(...series.map(l => abbr(l).length)) * 7.5) + 12)
const H = Math.round(Math.max(190, Math.min(320, width * 0.46)))
// One x scale shared by chart and strip: a point scale for BDC dates; for the
// yearly ACS series a numeric scale padded a half-step so the first/last
// value labels don't overflow the frame.
const xs = pdata.map(p => p.x)
// Sorted unique x values: the point-scale domain for BDC dates, and the
// explicit per-year ticks for the yearly ACS charts (so every year shows and
// lines up with the value strip, instead of Plot's default every-other-year).
const xvals = [...new Set(xs)].sort((a, b) => (a > b ? 1 : -1))
const xScale = yearly
? {domain: [Math.min(...xs) - 0.5, Math.max(...xs) + 0.5]}
: {type: "point", domain: xvals}
const node = Plot.plot({
width, height: H, marginLeft: ML, marginRight: 16,
style: {fontFamily: "Manrope, system-ui, sans-serif", fontSize: "13px"},
x: yearly ? {label: null, tickFormat: "d", ticks: xvals, ...xScale}
: {label: null, tickFormat: d => d.slice(0, 7), ...xScale},
y: {label: yLabel, domain: ydom, ticks: yticks, tickFormat: ".0%", grid: true},
color: {domain: series, range: colors},
marks: [
Plot.line(pdata, {x: "x", y: "value", stroke: "label", strokeWidth: 2.5}),
Plot.dot(pdata, {x: "x", y: "value", fill: "label", r: 3.5}),
Plot.tip(pdata, Plot.pointer({x: "x", y: "value",
title: d => `${d.label}\n${yearly ? d.x : d.x.slice(0, 7)} · ${(d.value * 100).toFixed(1)}%`})),
],
})
// Value strip = legend + raw readout: one row per series (colored, abbreviated
// label on the left), the one-decimal percent under each date via the shared
// x scale. Hidden axes; the chart's x ticks above label the columns.
const strip = Plot.plot({
width, marginLeft: ML, marginRight: 16, marginTop: 3, marginBottom: 3,
height: series.length * 20 + 6,
style: {fontFamily: "Manrope, system-ui, sans-serif", fontSize: "12.5px"},
x: {...xScale, axis: null},
y: {domain: series.map(abbr), axis: null},
marks: [
Plot.text(pdata, {x: "x", y: d => abbr(d.label),
text: d => (d.value * 100).toFixed(1), fill: "#333"}),
Plot.text(series.map(l => ({label: l})), {y: d => abbr(d.label),
text: d => abbr(d.label), fill: d => colorOf(d.label),
frameAnchor: "left", textAnchor: "end", dx: -8, fontWeight: 600}),
],
})
return card(title, html`<div>${node}
<div style="border-top:1px solid #eee;margin-top:3px">${strip}</div></div>`)
}
function barChart(dist, {title, xlabel, color, xTickFormat, marginBottom = 34, xLabelOffset, rowLabel = "Units", width = chartW}) {
if (!dist) return card(title, html`<div style="color:#999">No data.</div>`)
const data = dist.labels.map((l, i) => ({label: l, value: dist.values[i]}))
const total = data.reduce((s, d) => s + d.value, 0)
// Left margin shared by chart + strip, widened only if the strip's row label
// ("Units", "Locations", ...) needs more room than the y-axis already takes.
const ML = Math.max(52, Math.ceil(rowLabel.length * 7.5) + 12)
const H = Math.round(Math.max(190, Math.min(320, width * 0.46)))
const node = Plot.plot({
width, height: H, marginLeft: ML, marginRight: 16, marginBottom,
style: {fontFamily: "Manrope, system-ui, sans-serif", fontSize: "13px"},
x: {label: xlabel, domain: dist.labels, tickFormat: xTickFormat, labelOffset: xLabelOffset},
y: {label: "count", grid: true, tickFormat: fmtNum},
marks: [
Plot.barY(data, {x: "label", y: "value", fill: color || COLORS[1],
title: d => `${d.value.toLocaleString("en-US")} (${total ? (d.value / total * 100).toFixed(1) : "0"}%)`,
tip: true}),
Plot.ruleY([0]),
],
})
// Value strip: the raw count under each bar (centered via the same band x),
// with a plain (uncolored) row label on the left so the numbers aren't a
// mystery. Single series, so the label is grey, not a series color.
const strip = Plot.plot({
width, marginLeft: ML, marginRight: 16, marginTop: 3, marginBottom: 3,
height: 26,
style: {fontFamily: "Manrope, system-ui, sans-serif", fontSize: "12.5px"},
x: {type: "band", domain: dist.labels, axis: null},
y: {axis: null},
marks: [
Plot.text(data, {x: "label", text: d => fmtNum(d.value), frameAnchor: "top", fill: "#333"}),
Plot.text([0], {text: () => rowLabel, frameAnchor: "top-left",
textAnchor: "end", dx: -8, fill: "#666", fontWeight: 600}),
],
})
return card(title, html`<div>${node}
<div style="border-top:1px solid #eee;margin-top:3px">${strip}</div></div>`)
}dashboard = {
chartW; // depend on the responsive width so the charts re-render on resize
const vb = geo.value_box || {}
const bs = geo.bead_summary
const boxes = html`<div style="display:flex;gap:12px;flex-wrap:wrap;margin:8px 0 4px">
${valueBox("100/20 wired+FWA availability", pct(vb.pct_100_20), COLORS[0])}
${valueBox("Median fastest speed", fmtSpeed(vb.median_max_down), COLORS[1])}
${vb.pct_any_adoption != null
? valueBox("Any-broadband adoption (ACS)", pct(vb.pct_any_adoption), COLORS[3])
: valueBox("Residential units", fmtInt(geo.total_units), COLORS[5])}
</div>`
const availabilityCards = [
trendChart(relabel(geo.avail_trend, {"Gig": gigLabel}), {title: "Wired+FWA availability by speed tier"}),
trendChart(geo.mobile_trend, {title: "Mobile coverage by technology"}),
techTable("Technology available (any speed)", geo.tech_avail, geo.total_units),
barChart(geo.competition_dist, {title: "100/20 providers per unit", xlabel: "# providers", color: COLORS[1]}),
barChart(geo.speed_hist, {title: "Max download distribution", xlabel: "Mbps", color: COLORS[0], xTickFormat: d => d.replace("-", "\n"), marginBottom: 48, xLabelOffset: 42}),
trendChart(geo.multi_provider_trend, {title: "Units with ≥2 providers @100/20"}),
]
const adoptionCards = geo.adoption_trend
? [trendChart(geo.adoption_trend, {title: "Broadband adoption", minDate: "2016", yLabel: "% of households"}),
trendChart(geo.income_adoption, {title: "Broadband adoption by household income", minDate: "2016", yLabel: "% of households", colors: INCOME_COLORS}),
trendChart(geo.race_adoption, {title: "Broadband adoption by race / ethnicity", minDate: "2016", yLabel: "% of households", colors: RACE_COLORS})]
: geo.adoption_snapshot
? [pctTable("Broadband adoption", geo.adoption_snapshot),
pctTable("By household income", geo.income_snapshot),
pctTable("By race / ethnicity", geo.race_snapshot)]
: []
const beadCard = bs ? card("BEAD final proposal", html`<div style="padding:10px 2px 4px;display:flex;flex-direction:column;gap:12px">
${miniStat("planned locations", fmtInt(bs.locations))}
${miniStat("BEAD support", fmtMoney(bs.support))}
${miniStat("total match", fmtMoney(bs.match))}
</div>`) : ""
// BFM tables removed from display for now (kept in the output data).
const fundingCards = [
geo.bead_tech ? barChart(geo.bead_tech, {title: "BEAD planned locations by technology", xlabel: null, color: COLORS[2], rowLabel: "Locations"}) : "",
beadCard,
]
return html`<div>
<h2 style="margin-bottom:0">${geo.geo_name}${geo.geo_level === "county" ? `, ${geo.state_usps}` : ""}</h2>
${boxes}
${sectionBlock("Availability", availabilityCards)}
${sectionBlock(`Adoption${geo.acs_vintage ? " · " + geo.acs_vintage : ""}`, adoptionCards)}
${sectionBlock("Funding", fundingCards)}
</div>`
}{
const ml = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
const monthYear = ymd => { const [y, m] = ymd.split("-").map(Number); return `${ml[m - 1]} ${y}`; };
const fabricVer = (meta.vintages.fabric || "").split("-")[0];
// "as of" dates come from the build's meta.vintages, so they update on rebuild.
const sources = [
{name: "FCC Broadband Data Collection", asOf: monthYear(meta.vintages.bdc_fixed_latest)},
{name: `FCC Serviceable Location Fabric ${fabricVer}`},
{name: "US Census Bureau American Community Survey", asOf: String(meta.vintages.acs_latest_year)},
{name: "FCC Broadband Funding Map"},
{name: "BEAD Final Proposal", href: "/funding/BEADFinalProposalData.html"},
];
return html`<div style="font-size:.85em;color:#555;margin-top:8px">
${section("Sources")}
<ul style="line-height:1.55;margin-top:0">
${sources.map(s => html`<li>${s.href ? html`<a href="${s.href}">${s.name}</a>` : s.name}${s.asOf ? `, as of ${s.asOf}` : ""}</li>`)}
</ul>
</div>`;
}