From c9b033a61e11f85cc9a6a407443c69eeaaaa2473 Mon Sep 17 00:00:00 2001 From: Chandler Swift Date: Tue, 3 Mar 2026 22:28:13 -0600 Subject: [PATCH 1/3] Add layer for FCC tower registrations --- layers/fcc/towers/README.md | 1 + layers/fcc/towers/layer.js | 24 ++++++++++ layers/fcc/towers/pin.svg | 16 +++++++ layers/fcc/towers/process.py | 89 ++++++++++++++++++++++++++++++++++++ layers/index.js | 5 ++ 5 files changed, 135 insertions(+) create mode 100644 layers/fcc/towers/README.md create mode 100644 layers/fcc/towers/layer.js create mode 100644 layers/fcc/towers/pin.svg create mode 100755 layers/fcc/towers/process.py diff --git a/layers/fcc/towers/README.md b/layers/fcc/towers/README.md new file mode 100644 index 0000000..6e292cb --- /dev/null +++ b/layers/fcc/towers/README.md @@ -0,0 +1 @@ +https://data.fcc.gov/download/pub/uls/complete/r_tower.zip diff --git a/layers/fcc/towers/layer.js b/layers/fcc/towers/layer.js new file mode 100644 index 0000000..04855c6 --- /dev/null +++ b/layers/fcc/towers/layer.js @@ -0,0 +1,24 @@ +import VectorLayer from 'ol/layer/Vector'; +import {Vector as VectorSource} from 'ol/source.js'; +import GeoJSON from 'ol/format/GeoJSON.js'; + +import {Style} from 'ol/style.js'; +import Icon from 'ol/style/Icon.js'; + +import data from './data.geojson?url'; // TODO: remove `?url`? +import pinURL from './pin.svg?url'; // TODO: remove `?url`? + +const vectorLayer = new VectorLayer({ + source: new VectorSource({ + url: data, + format: new GeoJSON, + }), + style: new Style({ + image: new Icon({ + anchor: [0.5, 1], + src: pinURL, + }), + }), +}); + +export default vectorLayer; diff --git a/layers/fcc/towers/pin.svg b/layers/fcc/towers/pin.svg new file mode 100644 index 0000000..ad2a79a --- /dev/null +++ b/layers/fcc/towers/pin.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/layers/fcc/towers/process.py b/layers/fcc/towers/process.py new file mode 100755 index 0000000..479c789 --- /dev/null +++ b/layers/fcc/towers/process.py @@ -0,0 +1,89 @@ +#!/usr/bin/env nix-shell +#! nix-shell -i python3 -p python3 + +import csv +import io +import json +import zipfile + +fieldnames = { + "CO": ("Record Type", "Content Indicator", "File Number", "Registration Number", "Unique System Identifier", + "Coordinate Type", "Latitude Degrees", "Latitude Minutes", "Latitude Seconds", "Latitude Direction", + "Latitude_Total_Seconds", "Longitude Degrees", "Longitude Minutes", "Longitude Seconds", + "Longitude Direction", "Longitude Total Seconds", "Array Tower Position", "Array Total Tower"), + +} +filenames = ["CO", "EN", "HS", "RA", "RE", "SC"] +files = {} + +with zipfile.ZipFile("r_tower.zip", "r") as z: + for filename in filenames: + with z.open(f"{filename}.dat", "r") as f, io.TextIOWrapper( # Avoids `_csv.Error: iterator should return strings, not bytes (the file should be opened in text mode)` + f, encoding="utf-8", newline="", errors="replace" + ) as text_f: + contents = [] + # csv expects text rows; TextIOWrapper decodes bytes from the zip member + reader = csv.DictReader(text_f, delimiter="|", fieldnames=fieldnames.get(filename)) + for row in reader: + contents.append(row) + files[filename] = contents + +# CO data example: +#{ +# 'Record Type': 'CO', +# 'Content Indicator': 'REG', +# 'File Number': 'A0016772', +# 'Registration Number': '1014003', +# 'Unique System Identifier': '99845', +# 'Coordinate Type': 'T', +# 'Latitude Degrees': '18', +# 'Latitude Minutes': '26', +# 'Latitude Seconds': '18.0', +# 'Latitude Direction': 'N', +# 'Latitude_Total_Seconds': '66378.0', +# 'Longitude Degrees': '66', +# 'Longitude Minutes': '29', +# 'Longitude Seconds': '53.0', +# 'Longitude Direction': 'W', +# 'Longitude Total Seconds': '239393.0', +# 'Array Tower Position': '', +# 'Array Total Tower': '' +# } + +towers = [] + +for w in files["CO"]: + if w['Longitude Degrees'] == '' or w['Latitude Degrees'] == '': + print(f"Skipping tower with missing coordinates: {w}") + continue + towers.append({ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + (float(w['Longitude Degrees']) + + float(w['Longitude Minutes']) / 60 + + float(w['Longitude Seconds']) / 3600) * + (-1 if w['Longitude Direction'] == 'W' else 1), + (float(w['Latitude Degrees']) + + float(w['Latitude Minutes']) / 60 + + float(w['Latitude Seconds']) / 3600) * + (1 if w['Latitude Direction'] == 'N' else -1), + ], # yes, [lon, lat] since it's [x, y] + }, + "properties": { + "File Number": w['File Number'], + "Registration Number": w['Registration Number'], + "Unique System Identifier": w['Unique System Identifier'], + }, + }) + +print(f"""{len(towers)} towers found""") + +geojson = { + "type": "FeatureCollection", + "features": towers, +} + +with open("data.geojson", "w") as f: + f.write(json.dumps(geojson)) diff --git a/layers/index.js b/layers/index.js index 4f4a422..e0c9727 100644 --- a/layers/index.js +++ b/layers/index.js @@ -23,6 +23,7 @@ import minnesotaAdventureTrails from './minnesota-adventure-trails/index.js'; import cropHistory from './crop-history/index.js'; import mnAmbulanceServiceAreas from './mn-ambulance-service-areas/layer.js'; import upsServiceAreas from './ups/index.js'; +import fccTowersLayer from './fcc/towers/layer.js'; const layerCategories = [ { // Base maps @@ -93,6 +94,10 @@ const layerCategories = [ layer: bikepackingLayer, }, mnAmbulanceServiceAreas, + { + name: "FCC Towers", + layer: fccTowersLayer, + } ] }, upsServiceAreas, From 1e3b67ebddfbd97d861060e4ac11f851c8cbdf9e Mon Sep 17 00:00:00 2001 From: Chandler Swift Date: Tue, 3 Mar 2026 22:35:59 -0600 Subject: [PATCH 2/3] Download fcc tower data in script rather than expecting it present --- layers/fcc/towers/process.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/layers/fcc/towers/process.py b/layers/fcc/towers/process.py index 479c789..a91ae57 100755 --- a/layers/fcc/towers/process.py +++ b/layers/fcc/towers/process.py @@ -5,6 +5,7 @@ import csv import io import json import zipfile +import urllib.request fieldnames = { "CO": ("Record Type", "Content Indicator", "File Number", "Registration Number", "Unique System Identifier", @@ -15,8 +16,8 @@ fieldnames = { } filenames = ["CO", "EN", "HS", "RA", "RE", "SC"] files = {} - -with zipfile.ZipFile("r_tower.zip", "r") as z: +resp = urllib.request.urlopen("https://data.fcc.gov/download/pub/uls/complete/r_tower.zip") +with zipfile.ZipFile(io.BytesIO(resp.read())) as z: for filename in filenames: with z.open(f"{filename}.dat", "r") as f, io.TextIOWrapper( # Avoids `_csv.Error: iterator should return strings, not bytes (the file should be opened in text mode)` f, encoding="utf-8", newline="", errors="replace" From e39c23a1026d93e65c8fc14c4777f60089b13e12 Mon Sep 17 00:00:00 2001 From: Chandler Swift Date: Sun, 8 Mar 2026 20:08:33 -0500 Subject: [PATCH 3/3] Add remote deployment option From outside my network, I should route through the jump host. --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index bd33036..44f303d 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ deploy: build rsync --archive --verbose --delete dist/ root@bert:/srv/www/maps.chandlerswift.com/ +.PHONY: deploy-remote +deploy-remote: build + rsync --archive --verbose --delete dist/ root@bert-jump:/srv/www/maps.chandlerswift.com/ + .PHONY: clean clean: rm -r dist