Initial commit of Got Fiber

This commit is contained in:
Justin Oros
2026-10-07 12:21:45 -07:00
commit 8d75ee2aec
13 changed files with 1265 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
name: Update fiber data
on:
workflow_dispatch:
inputs:
states:
description: "Comma separated state codes like AZ,NM, or ALL. Blank uses the STATES variable."
required: false
default: ""
schedule:
- cron: "17 9 5 * *"
push:
branches: [main]
paths:
- "data/contracts/**"
permissions:
contents: write
concurrency:
group: update-data
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 340
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: pip install -r scripts/requirements.txt
- name: Build
env:
FCC_USERNAME: ${{ secrets.FCC_USERNAME }}
FCC_API_TOKEN: ${{ secrets.FCC_API_TOKEN }}
STATES: ${{ github.event.inputs.states || vars.STATES || 'AZ' }}
EVENT: ${{ github.event_name }}
run: |
if [ "$EVENT" = "push" ]; then
python scripts/build_fcc.py --manifest-only
else
python scripts/build_fcc.py --states "$STATES"
fi
- name: Commit
run: |
git config user.name "github-actions[bot]"
git config user.email "41898341+github-actions[bot]@users.noreply.github.com"
git add -A data
if git diff --cached --quiet; then
echo "No changes"
exit 0
fi
git commit -m "Update fiber data"
git push
+3
View File
@@ -0,0 +1,3 @@
.cache/
__pycache__/
*.part
View File
+86
View File
@@ -0,0 +1,86 @@
# Got Fiber
A static web app for GitHub Pages. Enter a US home address and it shows:
- Whether fiber internet is reported at that address, and by which providers at what speeds
- Every area within 50 miles where fiber is reported, shaded on a map
- Broadband contracts, awards, and open bids near the address (BEAD, RDOF, state grants)
## How it works
The page is plain HTML and JavaScript. It geocodes the address with OpenStreetMap Nominatim, then reads small JSON tiles from `data/`.
A GitHub Actions workflow downloads FCC National Broadband Map data (fixed broadband, technology code 50, fiber to the premises), aggregates it into H3 hexagons, and commits the tiles back to the repo. Nothing secret ever reaches the browser.
| Path | What it holds |
| --- | --- |
| `data/fiber/r8/` | Address level detail: H3 resolution 8 cells (about 0.28 sq mi) with providers and max speeds, sharded by resolution 5 parent |
| `data/fiber/r6/` | Map level detail: H3 resolution 6 cells (about 14 sq mi) with location counts, sharded by resolution 3 parent |
| `data/contracts/XX.json` | Hand maintained contracts and bids per state |
| `data/manifest.json` | Which states are loaded and the FCC data date |
## Setup
1. Create a GitHub repo and push these files to `main`.
2. Get an FCC API token: sign in at https://broadbandmap.fcc.gov, open the account menu, choose Manage API Access, and generate a token.
3. In the repo, go to Settings, Secrets and variables, Actions:
- Secret `FCC_USERNAME`: the email you use on the FCC site
- Secret `FCC_API_TOKEN`: the token from step 2
- Variable `STATES` (optional): `AZ` by default, a list like `AZ,NM,NV,UT,CA`, or `ALL`
4. Run Actions, Update fiber data, Run workflow. It repeats on the 5th of each month.
5. Turn on Settings, Pages, Deploy from a branch, `main`, `/ (root)`.
### Building without the API
Download the state files by hand from https://broadbandmap.fcc.gov/data-download (By State, Fixed Broadband, Fiber to the Premises) into a folder, then:
```
pip install -r scripts/requirements.txt
python scripts/build_fcc.py --local path/to/folder --as-of "June 30, 2025"
```
## Adding contracts and bids
Edit or add `data/contracts/XX.json` (XX is the state code). Pushing a change refreshes the manifest automatically.
```json
{
"state": "AZ",
"programs": [
{ "name": "Arizona BEAD", "office": "Arizona Commerce Authority", "url": "https://...", "summary": "One line status." }
],
"items": [
{
"id": "unique-id",
"program": "BEAD",
"awardee": "Provider name, or leave out for an open bid",
"status": "open | proposed | awarded | construction | complete | defaulted",
"technology": "Fiber",
"amount": 1000000,
"locations": 500,
"deadline": "2026-11-30",
"scope": "statewide | county | area",
"counties": ["Pima"],
"geometry": { "type": "Point", "coordinates": [-110.97, 32.22] },
"notes": "Anything useful",
"source": "https://...",
"updated": "2026-10"
}
]
}
```
An item shows for an address when its `geometry` (Point, Polygon, or MultiPolygon, in `[lng, lat]` order) is within 50 miles, when the address county is in `counties`, or when `scope` is `statewide`. Open bids are listed first and drawn in yellow.
Good sources for project areas: the FCC Broadband Funding Map (https://broadbandmap.fcc.gov/funding-map) for RDOF, ReConnect, and other federal awards, and each state broadband office for BEAD awards and open rounds.
## Coverage and limits
- US only: all 50 states, DC, and Puerto Rico. Canada publishes only coarse speed data with no technology breakdown, and Mexico has no usable public data.
- FCC data is self reported by providers and lags about six months. Always confirm with the provider.
- Address level results are per H3 cell, not per house, because the FCC location fabric with exact coordinates is licensed and not public.
- Nominatim allows about one search per second, which is fine for personal use.
## Credits
Fiber data from the FCC National Broadband Map. Map tiles by CARTO on OpenStreetMap data. Hexagons by Uber H3.
+226
View File
@@ -0,0 +1,226 @@
:root {
--ink: #17212B;
--muted: #5A6874;
--paper: #F2F4F5;
--panel: #FCFDFD;
--line: #D6DCE1;
--strand: #0A8F99;
--strand-deep: #06656C;
--jacket: #F0B400;
--contract: #A23B72;
--error: #9B2C2C;
--font: "Barlow", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--display: "Barlow Condensed", "Arial Narrow", "Roboto Condensed", sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--font);
font-size: 16px;
line-height: 1.45;
color: var(--ink);
background: var(--paper);
}
.app {
display: grid;
grid-template-columns: minmax(320px, 410px) 1fr;
height: 100%;
}
.panel {
display: flex;
flex-direction: column;
gap: 24px;
overflow-y: auto;
padding: 28px 26px 18px;
background: var(--panel);
border-right: 1px solid var(--line);
}
.map { height: 100%; min-height: 320px; background: #E4E8EA; }
h1 {
margin: 0;
font-family: var(--display);
font-weight: 700;
font-size: 2.75rem;
line-height: 0.95;
letter-spacing: -0.01em;
}
.lede { margin: 10px 0 0; color: var(--muted); max-width: 36ch; }
.search { display: flex; flex-direction: column; gap: 10px; }
.search input {
font: inherit;
padding: 12px 14px;
color: var(--ink);
background: #fff;
border: 1.5px solid var(--line);
border-radius: 6px;
}
.search input:focus-visible {
outline: none;
border-color: var(--strand);
box-shadow: 0 0 0 3px rgba(10, 143, 153, 0.22);
}
.search button {
font: 600 1rem var(--font);
padding: 12px 16px;
color: #fff;
background: var(--ink);
border: 0;
border-radius: 6px;
cursor: pointer;
}
.search button:hover { background: var(--strand-deep); }
.search button:focus-visible { outline: 3px solid var(--jacket); outline-offset: 2px; }
.search button[disabled] { opacity: 0.6; cursor: progress; }
.result { display: flex; flex-direction: column; gap: 26px; }
.result:empty { display: none; }
.address { margin: 0 0 6px; font-size: 0.92rem; color: var(--muted); }
.verdict h2 {
margin: 0;
font-family: var(--display);
font-weight: 700;
font-size: 2.2rem;
line-height: 1.02;
}
.verdict.yes h2 { color: var(--strand-deep); }
.verdict p { margin: 6px 0 0; color: var(--muted); }
.strand {
position: relative;
height: 4px;
margin-top: 14px;
overflow: hidden;
background: var(--line);
border-radius: 2px;
}
.verdict.yes .strand { background: var(--strand); }
.verdict.near .strand { background: linear-gradient(90deg, var(--strand) 0 55%, var(--line) 55%); }
.verdict.yes .strand::after,
.verdict.near .strand::after {
content: "";
position: absolute;
inset: 0;
width: 30%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.95), transparent);
transform: translateX(-120%);
animation: pulse 1.4s ease-out 0.15s 1 forwards;
}
@keyframes pulse { to { transform: translateX(420%); } }
.block h3 { margin: 0 0 6px; font: 600 1.05rem var(--font); }
.list { margin: 0; padding: 0; list-style: none; }
.list li {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 8px 0;
border-top: 1px solid var(--line);
}
.list li:first-child { border-top: 0; }
.list .sub { flex-shrink: 0; font-size: 0.9rem; color: var(--muted); }
.contract { margin: 0 0 14px; padding: 2px 0 4px 12px; border-left: 3px solid var(--contract); }
.contract.open { border-left-color: var(--jacket); }
.contract h4 { margin: 0; font-size: 1rem; }
.contract p { margin: 4px 0 0; font-size: 0.92rem; }
.facts {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-top: 4px;
font-size: 0.9rem;
color: var(--muted);
}
.badge {
display: inline-block;
padding: 1px 8px;
font-size: 0.8rem;
font-weight: 600;
color: var(--contract);
background: #F3E4EC;
border-radius: 10px;
}
.badge.open { color: #6B5000; background: #FCEFC4; }
.result a, .meta a { color: var(--strand-deep); }
.note { margin: 0; font-size: 0.92rem; color: var(--muted); }
.error { margin: 0; color: var(--error); }
.meta {
margin-top: auto;
padding-top: 12px;
font-size: 0.82rem;
color: var(--muted);
border-top: 1px solid var(--line);
}
.meta p { margin: 0 0 4px; }
.pin {
width: 18px;
height: 18px;
background: var(--jacket);
border: 3px solid var(--ink);
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(240, 180, 0, 0.35);
}
.legend {
padding: 8px 10px;
font: 0.82rem var(--font);
color: var(--ink);
background: rgba(252, 253, 253, 0.94);
border: 1px solid var(--line);
border-radius: 6px;
}
.legend div { display: flex; align-items: center; gap: 8px; }
.legend i { display: inline-block; width: 14px; height: 10px; border-radius: 2px; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.strand::after { display: none; }
}
@media (max-width: 760px) {
html, body { height: auto; }
.app { grid-template-columns: 1fr; grid-template-rows: 44vh auto; height: auto; }
.map { grid-row: 1; height: 44vh; }
.panel { grid-row: 2; overflow: visible; border-right: 0; border-top: 1px solid var(--line); padding: 22px 18px 16px; }
h1 { font-size: 2.3rem; }
}
+61
View File
@@ -0,0 +1,61 @@
{
"state": "AZ",
"programs": [
{
"name": "Arizona BEAD",
"office": "Arizona Commerce Authority",
"url": "https://www.azcommerce.com/broadband/initiatives/bead/",
"summary": "NTIA approved the final proposal. Fiber is planned for 118,170 locations, with builds scheduled through 2029."
}
],
"items": [
{
"id": "az-bead-wecom",
"program": "BEAD",
"awardee": "Wecom Fiber",
"status": "awarded",
"technology": "Fiber and LEO satellite",
"amount": 195811710,
"locations": 84135,
"scope": "statewide",
"notes": "Largest Arizona BEAD award. Project areas are not mapped here yet.",
"source": "https://brandergroup.net/2025/12/wecom-fiber-wins-195m-bead-fiber-fund-in-arizona/",
"updated": "2026-02"
},
{
"id": "az-bead-navajo",
"program": "BEAD",
"awardee": "Navajo Nation",
"status": "awarded",
"technology": "Fiber",
"scope": "statewide",
"notes": "Large provisional award covering Navajo Nation lands. Amount not listed here.",
"source": "https://brandergroup.net/?p=25689",
"updated": "2025-10"
},
{
"id": "az-bead-pima-comcast",
"program": "BEAD",
"awardee": "Comcast",
"status": "awarded",
"technology": "Fiber",
"scope": "county",
"counties": ["Pima"],
"notes": "Named in the Pima County memo on the Arizona BEAD final proposal.",
"source": "https://content.civicplus.com/api/assets/bbf85f09-7433-4e49-bda3-171ec3187acb",
"updated": "2025-08"
},
{
"id": "az-bead-pima-trico",
"program": "BEAD",
"awardee": "Trico Electric Cooperative",
"status": "awarded",
"technology": "Fiber",
"scope": "county",
"counties": ["Pima"],
"notes": "Named in the Pima County memo on the Arizona BEAD final proposal.",
"source": "https://content.civicplus.com/api/assets/bbf85f09-7433-4e49-bda3-171ec3187acb",
"updated": "2025-08"
}
]
}
View File
View File
+10
View File
@@ -0,0 +1,10 @@
{
"generated": null,
"asOf": null,
"states": [],
"contractStates": ["AZ"],
"fineRes": 8,
"fineShardRes": 5,
"coarseRes": 6,
"coarseShardRes": 3
}
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Got Fiber</title>
<meta name="description" content="Check whether fiber internet is reported at a US address and see broadband contracts and bids within 50 miles.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='9' fill='%23F0B400' stroke='%2317212B' stroke-width='4'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600&family=Barlow+Condensed:wght@600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<main class="app">
<aside class="panel">
<header>
<h1>Got Fiber?</h1>
<p class="lede">Enter a home address to see if fiber internet is reported there, who offers it, and which broadband contracts and bids are within 50 miles.</p>
</header>
<form id="search" class="search">
<label for="address" class="sr-only">Home address</label>
<input id="address" name="address" type="text" placeholder="123 Main St, Kingman, AZ" autocomplete="street-address" required>
<button id="go" type="submit">Check address</button>
</form>
<div id="result" class="result" aria-live="polite"></div>
<footer id="meta" class="meta"></footer>
</aside>
<div id="map" class="map" role="region" aria-label="Map of fiber coverage near the address"></div>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/h3-js@4.1.0/dist/h3-js.umd.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+466
View File
@@ -0,0 +1,466 @@
const RADIUS_KM = 80.4672;
const KM_PER_MI = 1.609344;
const COLORS = { strand: "#0A8F99", jacket: "#F0B400", ink: "#17212B", contract: "#A23B72" };
const STATUS = {
open: "Open for bids",
proposed: "Proposed",
awarded: "Awarded",
construction: "Under construction",
complete: "Built",
defaulted: "Defaulted"
};
const S = {
manifest: null,
map: null,
layers: {},
fine: new Map(),
coarse: new Map(),
contracts: new Map()
};
const $ = id => document.getElementById(id);
function el(tag, cls, text) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" })[c]);
}
async function getJSON(url) {
try {
const r = await fetch(url, { cache: "no-cache" });
return r.ok ? await r.json() : null;
} catch {
return null;
}
}
function fmtSpeed(mbps) {
if (!mbps) return "speed not listed";
if (mbps >= 1000) {
const g = mbps / 1000;
return `${Number.isInteger(g) ? g : g.toFixed(1)} Gig`;
}
return `${mbps} Mbps`;
}
const fmtInt = n => Math.round(n).toLocaleString("en-US");
const fmtMoney = n => "$" + Math.round(n).toLocaleString("en-US");
function haversineKm(a, b) {
const R = 6371.0088;
const rad = Math.PI / 180;
const dLat = (b[0] - a[0]) * rad;
const dLng = (b[1] - a[1]) * rad;
const s = Math.sin(dLat / 2) ** 2 + Math.cos(a[0] * rad) * Math.cos(b[0] * rad) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
}
function inRing(pt, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if ((yi > pt[1]) !== (yj > pt[1]) && pt[0] < ((xj - xi) * (pt[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function geomDistanceKm(geom, latlng) {
const pt = [latlng[1], latlng[0]];
const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.type === "MultiPolygon" ? geom.coordinates : [];
if (polys.some(p => inRing(pt, p[0]) && !p.slice(1).some(h => inRing(pt, h)))) return 0;
let best = Infinity;
const visit = c => {
if (typeof c[0] === "number") best = Math.min(best, haversineKm(latlng, [c[1], c[0]]));
else c.forEach(visit);
};
visit(geom.coordinates);
return best;
}
function initMap() {
const map = L.map("map", { preferCanvas: true }).setView([34.3, -111.7], 6);
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
maxZoom: 19,
subdomains: "abcd",
attribution: "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> &copy; <a href=\"https://carto.com/attributions\">CARTO</a>"
}).addTo(map);
S.layers.fiber = L.layerGroup().addTo(map);
S.layers.contracts = L.layerGroup().addTo(map);
S.layers.focus = L.layerGroup().addTo(map);
const legend = L.control({ position: "bottomright" });
legend.onAdd = () => {
const d = L.DomUtil.create("div", "legend");
d.innerHTML =
`<div><i style="background:${COLORS.strand};opacity:.55"></i>Fiber reported</div>` +
`<div><i style="background:${COLORS.contract};opacity:.55"></i>Contract or bid area</div>` +
`<div><i style="border:2px dashed ${COLORS.ink}"></i>50 miles</div>`;
return d;
};
legend.addTo(map);
S.map = map;
}
async function geocode(q) {
const url = "https://nominatim.openstreetmap.org/search?" + new URLSearchParams({
q,
format: "jsonv2",
addressdetails: "1",
limit: "1",
countrycodes: "us"
});
const r = await fetch(url, { headers: { "Accept-Language": "en" } });
if (!r.ok) throw new Error("The address service did not respond. Try again in a minute.");
const list = await r.json();
if (!list.length) return null;
const hit = list[0];
const a = hit.address || {};
const iso = a["ISO3166-2-lvl4"] || "";
return {
lat: Number(hit.lat),
lng: Number(hit.lon),
label: hit.display_name.split(",").slice(0, 4).join(",").trim(),
state: iso.startsWith("US-") ? iso.slice(3) : null,
county: (a.county || "").replace(/\s+County$/i, "").trim()
};
}
function shard(cache, dir, id) {
if (!cache.has(id)) cache.set(id, getJSON(`data/fiber/${dir}/${id}.json`));
return cache.get(id);
}
async function fiberAt(lat, lng) {
const m = S.manifest;
const cell = h3.latLngToCell(lat, lng, m.fineRes);
const ring = h3.gridDisk(cell, 1);
const ids = [...new Set(ring.map(c => h3.cellToParent(c, m.fineShardRes)))];
const docs = new Map(await Promise.all(ids.map(async id => [id, await shard(S.fine, "r8", id)])));
const read = c => {
const d = docs.get(h3.cellToParent(c, m.fineShardRes));
const v = d && d.c[c];
if (!v) return null;
return { cell: c, count: v[0], providers: v[1].map(([i, down, up]) => ({ name: d.p[i], down, up })) };
};
return { cell, own: read(cell), near: ring.filter(c => c !== cell).map(read).filter(Boolean) };
}
async function fiberRegion(lat, lng) {
const m = S.manifest;
const ids = h3.gridDisk(h3.latLngToCell(lat, lng, m.coarseShardRes), 2);
const docs = await Promise.all(ids.map(id => shard(S.coarse, "r6", id)));
const cells = [];
const totals = new Map();
for (const d of docs) {
if (!d) continue;
for (const [c, v] of Object.entries(d.c)) {
if (haversineKm([lat, lng], h3.cellToLatLng(c)) > RADIUS_KM) continue;
const names = v[1].map(i => d.p[i]);
cells.push({ cell: c, count: v[0], names });
for (const n of names) totals.set(n, (totals.get(n) || 0) + 1);
}
}
return { cells, providers: [...totals.entries()].sort((a, b) => b[1] - a[1]) };
}
async function contractsNear(place) {
const states = S.manifest.contractStates || [];
const docs = await Promise.all(states.map(async st => {
if (!S.contracts.has(st)) S.contracts.set(st, getJSON(`data/contracts/${st}.json`));
return [st, await S.contracts.get(st)];
}));
const items = [];
const programs = [];
const county = place.county.toLowerCase();
for (const [st, d] of docs) {
if (!d) continue;
const home = st === place.state;
if (home) programs.push(...(d.programs || []));
for (const it of d.items || []) {
let where = null;
let distance = null;
if (it.geometry) {
const km = geomDistanceKm(it.geometry, [place.lat, place.lng]);
if (km <= RADIUS_KM) {
distance = km;
where = km === 0 ? "Covers this address" : `${Math.max(1, Math.round(km / KM_PER_MI))} mi away`;
}
}
if (!where && home && county && (it.counties || []).some(c => c.toLowerCase() === county)) where = `${place.county} County`;
if (!where && home && it.scope === "statewide") where = "Statewide";
if (where) items.push({ ...it, state: st, where, distance });
}
}
const rank = it => (it.distance != null ? it.distance : it.where === "Statewide" ? 1e6 : 1e5);
items.sort((a, b) => (a.status === "open" ? 0 : 1) - (b.status === "open" ? 0 : 1) || rank(a) - rank(b));
return { items, programs };
}
function drawFocus(place, at) {
const g = S.layers.focus;
g.clearLayers();
L.circle([place.lat, place.lng], {
radius: RADIUS_KM * 1000,
color: COLORS.ink,
weight: 1.5,
dashArray: "6 6",
fill: false,
interactive: false
}).addTo(g);
if (at) {
L.polygon(h3.cellToBoundary(at.cell), { color: COLORS.jacket, weight: 2, fill: false, interactive: false }).addTo(g);
}
L.marker([place.lat, place.lng], {
icon: L.divIcon({ className: "", html: "<div class=\"pin\"></div>", iconSize: [18, 18], iconAnchor: [9, 9] }),
title: place.label,
keyboard: false
}).addTo(g);
}
function drawRegion(region) {
const g = S.layers.fiber;
g.clearLayers();
if (!region) return;
const max = region.cells.reduce((m, c) => Math.max(m, c.count), 1);
for (const c of region.cells) {
const t = Math.log1p(c.count) / Math.log1p(max);
const names = c.names.slice(0, 4).map(esc).join(", ") + (c.names.length > 4 ? `, +${c.names.length - 4} more` : "");
L.polygon(h3.cellToBoundary(c.cell), {
color: COLORS.strand,
weight: 0.5,
opacity: 0.4,
fillColor: COLORS.strand,
fillOpacity: 0.12 + 0.5 * t
}).bindTooltip(`<strong>${fmtInt(c.count)} fiber locations</strong><br>${names}`, { sticky: true }).addTo(g);
}
}
function drawContracts(items) {
const g = S.layers.contracts;
g.clearLayers();
for (const it of items) {
if (!it.geometry) continue;
const color = it.status === "open" ? COLORS.jacket : COLORS.contract;
L.geoJSON(it.geometry, {
style: { color, weight: 2, fillColor: color, fillOpacity: 0.12 },
pointToLayer: (f, ll) => L.circleMarker(ll, { radius: 7, color, weight: 2, fillColor: color, fillOpacity: 0.5 })
}).bindTooltip(`<strong>${esc(it.awardee || "Open bid")}</strong><br>${esc(it.program || "")}`, { sticky: true }).addTo(g);
}
}
function verdictFor(place, covered, at, region) {
if (!covered) {
const list = S.manifest.states.length ? S.manifest.states.join(", ") : "no states yet";
return {
kind: "unknown",
title: "No fiber data for this state yet",
text: `Fiber data currently covers ${list}. Add ${place.state || "this state"} to the data build to check this address.`
};
}
if (at.own) {
return {
kind: "yes",
title: "Fiber is available here",
text: `Reported at ${fmtInt(at.own.count)} ${at.own.count === 1 ? "location" : "locations"} in the quarter square mile around this address.`
};
}
if (at.near.length) {
const n = at.near.reduce((s, c) => s + c.count, 0);
return {
kind: "near",
title: "Fiber is close by",
text: `Not reported on this block, but reported at ${fmtInt(n)} ${n === 1 ? "location" : "locations"} within about half a mile.`
};
}
return {
kind: "no",
title: "No fiber reported here",
text: region && region.cells.length ? "Fiber is reported elsewhere within 50 miles. See the map." : "No fiber is reported within 50 miles of this address."
};
}
function mergeProviders(cells) {
const m = new Map();
for (const c of cells) {
for (const p of c.providers) {
const e = m.get(p.name);
if (!e) m.set(p.name, { ...p });
else {
e.down = Math.max(e.down, p.down);
e.up = Math.max(e.up, p.up);
}
}
}
return [...m.values()].sort((a, b) => b.down - a.down || a.name.localeCompare(b.name));
}
function providerList(providers) {
const ul = el("ul", "list");
for (const p of providers) {
const li = el("li");
li.append(el("span", null, p.name), el("span", "sub", `${fmtSpeed(p.down)} down, ${fmtSpeed(p.up)} up`));
ul.append(li);
}
return ul;
}
function contractCard(it) {
const card = el("article", "contract" + (it.status === "open" ? " open" : ""));
card.append(el("h4", null, it.awardee || it.title || "Open bid"));
const facts = el("div", "facts");
const badge = el("span", "badge" + (it.status === "open" ? " open" : ""), STATUS[it.status] || it.status || "Listed");
facts.append(badge, el("span", null, it.where));
if (it.program) facts.append(el("span", null, it.program));
if (it.technology) facts.append(el("span", null, it.technology));
if (it.amount != null) facts.append(el("span", null, fmtMoney(it.amount)));
if (it.locations != null) facts.append(el("span", null, `${fmtInt(it.locations)} locations`));
if (it.deadline) facts.append(el("span", null, `Bids due ${it.deadline}`));
card.append(facts);
if (it.notes) card.append(el("p", null, it.notes));
if (it.source) {
const p = el("p");
const a = el("a", null, "Source");
a.href = it.source;
a.target = "_blank";
a.rel = "noopener";
p.append(a);
if (it.updated) p.append(` (as of ${it.updated})`);
card.append(p);
}
return card;
}
function render(place, covered, at, region, deals) {
const root = $("result");
root.replaceChildren();
const v = verdictFor(place, covered, at, region);
const vb = el("section", `verdict ${v.kind}`);
vb.append(el("p", "address", place.label), el("h2", null, v.title), el("p", null, v.text), el("div", "strand"));
root.append(vb);
if (covered && (at.own || at.near.length)) {
const b = el("section", "block");
b.append(el("h3", null, at.own ? "Providers at this address" : "Providers close by"));
b.append(providerList(mergeProviders(at.own ? [at.own] : at.near)));
root.append(b);
}
if (covered) {
const b = el("section", "block");
b.append(el("h3", null, "Fiber within 50 miles"));
if (region.cells.length) {
b.append(el("p", "note", `Fiber is reported in ${fmtInt(region.cells.length)} of the map areas within 50 miles.`));
const ul = el("ul", "list");
for (const [name, n] of region.providers.slice(0, 8)) {
const li = el("li");
li.append(el("span", null, name), el("span", "sub", `${fmtInt(n)} ${n === 1 ? "area" : "areas"}`));
ul.append(li);
}
b.append(ul);
if (region.providers.length > 8) b.append(el("p", "note", `Plus ${region.providers.length - 8} more providers. Hover the map for details.`));
} else {
b.append(el("p", "note", "No fiber reported within 50 miles."));
}
root.append(b);
}
const cb = el("section", "block");
cb.append(el("h3", null, "Contracts and bids"));
if (deals.items.length) deals.items.forEach(it => cb.append(contractCard(it)));
else cb.append(el("p", "note", "No broadband contracts or open bids are listed near this address."));
for (const p of deals.programs) {
const n = el("p", "note");
n.append(`${p.name}: ${p.summary} `);
if (p.url) {
const a = el("a", null, p.office || "Program page");
a.href = p.url;
a.target = "_blank";
a.rel = "noopener";
n.append(a);
}
cb.append(n);
}
root.append(cb);
}
function renderError(msg) {
const root = $("result");
root.replaceChildren(el("p", "error", msg));
}
function renderMeta() {
const m = S.manifest;
const f = $("meta");
f.replaceChildren();
const p1 = el("p");
if (m.asOf) {
p1.append(`Fiber data: FCC National Broadband Map, as of ${m.asOf}. Providers report their own coverage, so confirm with the provider before you sign up. `);
} else {
p1.append("Fiber data has not been built yet. Run the Update fiber data workflow. ");
}
const a = el("a", null, "Check the FCC map");
a.href = "https://broadbandmap.fcc.gov/";
a.target = "_blank";
a.rel = "noopener";
p1.append(a);
f.append(p1);
f.append(el("p", null, "Address search by OpenStreetMap Nominatim."));
}
async function check(q) {
const btn = $("go");
btn.disabled = true;
btn.textContent = "Checking...";
try {
const place = await geocode(q);
if (!place) {
renderError("No match for that address. Add the city and state, then try again.");
return;
}
const covered = S.manifest.states.includes(place.state);
const [at, region, deals] = await Promise.all([
covered ? fiberAt(place.lat, place.lng) : null,
covered ? fiberRegion(place.lat, place.lng) : null,
contractsNear(place)
]);
drawRegion(region);
drawContracts(deals.items);
drawFocus(place, at);
S.map.fitBounds(L.latLng(place.lat, place.lng).toBounds(RADIUS_KM * 2000), { padding: [10, 10] });
render(place, covered, at, region, deals);
const url = new URL(location.href);
url.searchParams.set("q", q);
history.replaceState(null, "", url);
} catch (e) {
renderError(e.message || "Something went wrong loading the data. Reload the page and try again.");
} finally {
btn.disabled = false;
btn.textContent = "Check address";
}
}
async function main() {
initMap();
S.manifest = (await getJSON("data/manifest.json")) || { states: [], contractStates: [], fineRes: 8, fineShardRes: 5, coarseRes: 6, coarseShardRes: 3 };
renderMeta();
$("search").addEventListener("submit", e => {
e.preventDefault();
const q = $("address").value.trim();
if (q) check(q);
});
const q = new URLSearchParams(location.search).get("q");
if (q) {
$("address").value = q;
check(q);
}
}
main();
+315
View File
@@ -0,0 +1,315 @@
import argparse
import csv
import io
import json
import os
import shutil
import sys
import time
import urllib.request
import zipfile
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
import h3
API = "https://broadbandmap.fcc.gov/api/public/map"
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
CACHE = ROOT / ".cache"
FINE, FINE_SHARD, COARSE, COARSE_SHARD = 8, 5, 6, 3
FIBER_TECH = "50"
FIPS = {
"AL": "01", "AK": "02", "AZ": "04", "AR": "05", "CA": "06", "CO": "08", "CT": "09", "DE": "10",
"DC": "11", "FL": "12", "GA": "13", "HI": "15", "ID": "16", "IL": "17", "IN": "18", "IA": "19",
"KS": "20", "KY": "21", "LA": "22", "ME": "23", "MD": "24", "MA": "25", "MI": "26", "MN": "27",
"MS": "28", "MO": "29", "MT": "30", "NE": "31", "NV": "32", "NH": "33", "NJ": "34", "NM": "35",
"NY": "36", "NC": "37", "ND": "38", "OH": "39", "OK": "40", "OR": "41", "PA": "42", "RI": "44",
"SC": "45", "SD": "46", "TN": "47", "TX": "48", "UT": "49", "VT": "50", "VA": "51", "WA": "53",
"WV": "54", "WI": "55", "WY": "56", "PR": "72",
}
USPS = {v: k for k, v in FIPS.items()}
def log(msg):
print(msg, flush=True)
def request(path, user, token):
return urllib.request.Request(
API + path,
headers={
"username": user,
"hash_value": token,
"User-Agent": "got-fiber-build/1.0",
"Accept": "application/json, application/zip, */*",
},
)
def api_json(path, user, token):
for attempt in range(5):
try:
with urllib.request.urlopen(request(path, user, token), timeout=300) as r:
return json.loads(r.read())
except Exception as e:
if attempt == 4:
raise
log(f" retry {path}: {e}")
time.sleep(10 * (attempt + 1))
def api_download(path, user, token, dest):
tmp = dest.with_suffix(".part")
for attempt in range(5):
try:
with urllib.request.urlopen(request(path, user, token), timeout=900) as r, open(tmp, "wb") as f:
shutil.copyfileobj(r, f, 1 << 20)
tmp.replace(dest)
return dest
except Exception as e:
if attempt == 4:
raise
log(f" retry download: {e}")
time.sleep(15 * (attempt + 1))
def latest_as_of(user, token):
d = api_json("/listAsOfDates", user, token)
dates = sorted(x["as_of_date"][:10] for x in d.get("data", []) if x.get("data_type") == "availability")
if not dates:
sys.exit("FCC API returned no availability dates. Check FCC_USERNAME and FCC_API_TOKEN.")
return dates[-1]
def fiber_files(as_of, fips_wanted, user, token):
d = api_json(f"/downloads/listAvailabilityData/{as_of}?category=State", user, token)
rows = d.get("data", [])
out = defaultdict(list)
for f in rows:
fips = str(f.get("state_fips") or "").zfill(2)
if fips not in fips_wanted:
continue
if (f.get("category") or "State") != "State":
continue
if "fixed" not in (f.get("subcategory") or "fixed").lower():
continue
tech = str(f.get("technology_code") or "").strip()
name = (f.get("file_name") or "").lower()
if tech != FIBER_TECH and not (not tech and "fiber" in name):
continue
if (f.get("file_type") or "csv").lower() != "csv":
continue
out[fips].append(f)
if not out and rows:
log("No fiber files matched. Sample entries from the FCC listing:")
for f in rows[:5]:
log(" " + json.dumps(f))
return out
def to_int(v):
try:
return int(float(v))
except (TypeError, ValueError):
return 0
def iter_csv(path):
if path.suffix.lower() == ".zip":
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if name.lower().endswith(".csv"):
with z.open(name) as fh:
yield from csv.DictReader(io.TextIOWrapper(fh, encoding="utf-8-sig", newline=""))
else:
with open(path, encoding="utf-8-sig", newline="") as fh:
yield from csv.DictReader(fh)
def ingest(path, cells, states_seen):
n = 0
for r in iter_csv(path):
if str(r.get("technology", "")).strip() != FIBER_TECH:
continue
h = (r.get("h3_res8_id") or "").strip().lower()
if not h or not h3.is_valid_cell(h):
continue
st = (r.get("state_usps") or "").strip().upper()
if st:
states_seen.add(st)
e = cells.get(h)
if e is None:
e = cells[h] = [set(), {}]
e[0].add(r.get("location_id") or f"row{n}")
name = (r.get("brand_name") or "").strip() or f"Provider {r.get('provider_id', '?')}"
down = to_int(r.get("max_advertised_download_speed"))
up = to_int(r.get("max_advertised_upload_speed"))
p = e[1].get(name)
if p is None:
e[1][name] = [down, up]
else:
p[0] = max(p[0], down)
p[1] = max(p[1], up)
n += 1
return n
def fold(total, cells):
for h, (locs, provs) in cells.items():
t = total.get(h)
if t is None:
total[h] = [len(locs), provs]
continue
t[0] += len(locs)
for name, (d, u) in provs.items():
p = t[1].get(name)
if p is None:
t[1][name] = [d, u]
else:
p[0] = max(p[0], d)
p[1] = max(p[1], u)
def reset_dir(p):
if p.exists():
shutil.rmtree(p)
p.mkdir(parents=True)
(p / ".gitkeep").touch()
def dump(path, obj):
path.write_text(json.dumps(obj, separators=(",", ":"), sort_keys=True), encoding="utf-8")
def write_shards(total):
r8, r6 = DATA / "fiber" / "r8", DATA / "fiber" / "r6"
reset_dir(r8)
reset_dir(r6)
fine_shards = defaultdict(dict)
coarse = {}
for h, (n, provs) in total.items():
fine_shards[h3.cell_to_parent(h, FINE_SHARD)][h] = (n, provs)
c = h3.cell_to_parent(h, COARSE)
e = coarse.setdefault(c, [0, set()])
e[0] += n
e[1].update(provs)
for sid, cells in fine_shards.items():
names = sorted({p for _, provs in cells.values() for p in provs})
idx = {p: i for i, p in enumerate(names)}
dump(r8 / f"{sid}.json", {
"p": names,
"c": {h: [n, [[idx[p], v[0], v[1]] for p, v in sorted(provs.items())]] for h, (n, provs) in cells.items()},
})
coarse_shards = defaultdict(dict)
for c, v in coarse.items():
coarse_shards[h3.cell_to_parent(c, COARSE_SHARD)][c] = v
for sid, cells in coarse_shards.items():
names = sorted({p for _, provs in cells.values() for p in provs})
idx = {p: i for i, p in enumerate(names)}
dump(r6 / f"{sid}.json", {
"p": names,
"c": {c: [n, sorted(idx[p] for p in provs)] for c, (n, provs) in cells.items()},
})
log(f"Wrote {len(fine_shards)} fine shards and {len(coarse_shards)} coarse shards for {len(total)} cells")
def write_manifest(as_of=None, states=None):
path = DATA / "manifest.json"
old = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
contract_states = sorted(p.stem for p in (DATA / "contracts").glob("*.json"))
m = {
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"asOf": as_of if as_of is not None else old.get("asOf"),
"states": sorted(states) if states is not None else old.get("states", []),
"contractStates": contract_states,
"fineRes": FINE,
"fineShardRes": FINE_SHARD,
"coarseRes": COARSE,
"coarseShardRes": COARSE_SHARD,
"source": "FCC National Broadband Map, fixed broadband availability, technology code 50 (fiber to the premises)",
}
path.write_text(json.dumps(m, indent=2) + "\n", encoding="utf-8")
log(f"Manifest: states={m['states']} asOf={m['asOf']} contracts={contract_states}")
def parse_states(s):
s = (s or "").strip().upper()
if not s or s == "ALL":
return sorted(FIPS)
out = [x.strip() for x in s.replace(" ", ",").split(",") if x.strip()]
bad = [x for x in out if x not in FIPS]
if bad:
sys.exit(f"Unknown state codes: {', '.join(bad)}")
return sorted(set(out))
def run_api(states):
user = os.environ.get("FCC_USERNAME", "").strip()
token = os.environ.get("FCC_API_TOKEN", "").strip()
if not user or not token:
sys.exit("Set FCC_USERNAME and FCC_API_TOKEN.")
as_of = latest_as_of(user, token)
log(f"Latest FCC availability data: {as_of}")
wanted = {FIPS[s] for s in states}
files = fiber_files(as_of, wanted, user, token)
missing = sorted(USPS[f] for f in wanted if f not in files)
if missing:
log(f"No fiber file listed for: {', '.join(missing)}")
CACHE.mkdir(exist_ok=True)
total, done = {}, set()
for fips in sorted(files):
st = USPS[fips]
cells, seen = {}, set()
for f in files[fips]:
fid = f.get("file_id")
dest = CACHE / f"{as_of}_{fid}.zip"
if not dest.exists():
log(f"{st}: downloading {f.get('file_name', fid)}")
api_download(f"/downloads/downloadFile/availability/{fid}", user, token, dest)
n = ingest(dest, cells, seen)
log(f"{st}: {n} fiber rows, {len(cells)} cells")
dest.unlink()
fold(total, cells)
done.add(st)
return as_of, total, done
def run_local(folder, as_of):
total, done = {}, set()
paths = sorted(p for p in Path(folder).iterdir() if p.suffix.lower() in (".zip", ".csv"))
if not paths:
sys.exit(f"No .zip or .csv files in {folder}")
for p in paths:
cells, seen = {}, set()
n = ingest(p, cells, seen)
log(f"{p.name}: {n} fiber rows, {len(cells)} cells, states {sorted(seen)}")
fold(total, cells)
done |= seen
return as_of, total, done
def main():
ap = argparse.ArgumentParser(description="Build fiber availability tiles from FCC National Broadband Map data")
ap.add_argument("--states", default=os.environ.get("STATES", "AZ"), help="Comma separated USPS codes, or ALL")
ap.add_argument("--local", help="Folder of FCC state fixed broadband CSV or ZIP files downloaded by hand")
ap.add_argument("--as-of", default="local data", help="Date label to show when using --local")
ap.add_argument("--manifest-only", action="store_true", help="Only refresh data/manifest.json")
a = ap.parse_args()
if a.manifest_only:
write_manifest()
return
if a.local:
as_of, total, done = run_local(a.local, a.as_of)
else:
as_of, total, done = run_api(parse_states(a.states))
if not total:
sys.exit("No fiber rows found. Nothing written.")
write_shards(total)
write_manifest(as_of, done)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
h3>=4.1,<5