Building a UK repeater map (and the tx/rx trap that nearly caught me)
Turning the RSGB ETCC's repeater CSV into an interactive map with distances, band and mode filters — and why the most dangerous bug in the whole project was a column name.
I use 70cm repeaters. I’ve never tried a 2m one. That’s not a principled position, it’s just what got programmed into my radio first and never changed — and when I’m travelling around the UK, “what can I hit from here?” turns into a browser tab, a table of several hundred rows, and a squint.
ukrepeater.net is the source of truth for UK repeaters. The RSGB’s Emerging Technology Co-ordination Committee maintain it, it’s free, no registration, no ads. The data is excellent. The interface is a table. Sorting a table by “how far is this from me” is not a thing a table does, and “Mode A” means nothing to me when I’m standing in a lay-by trying to work out if my handheld can talk to it.
So I built a map.
The data source
The ETCC publish CSV exports, which is genuinely great of them. There are several — voice only, gateways, packet — but the one I wanted was “All Modes”, covering analogue, DMR, D-STAR and Fusion. The header tells you most of what you need to know:
"CALL","BAND","CHAN","txMHz","rxMHz","CTCSS","QTHR","WHERE","lat","lon","ANALOG","DMR","DSTAR","FUSION"
804 rows. Callsign, band, channel designation, both frequencies, the access tone, a Maidenhead locator, a town name, coordinates, and four yes/no columns for the modes.
I’d braced myself for having to convert Maidenhead locators to coordinates — it’s a well-documented conversion, but it’s fiddly and it’s another thing to get wrong. Then I read the header again and there they were: lat and lon, already done. That’s the whole geocoding problem solved before it started. More on the accuracy of those in a moment, because it’s not quite as clean as it looks.
The trap: whose transmit is it anyway?
Here’s the bug I didn’t ship, and the reason I’m writing this section first.
The CSV columns are txMHz and rxMHz. Obvious, right? Except: those are from the repeater’s point of view.
txMHz is the frequency the repeater transmits on. Which means it’s the frequency your radio needs to receive on. And rxMHz — the repeater’s receive — is what your radio must transmit on.
They’re exactly backwards from what you type into a handheld. Copy those column names straight through to your UI labels and you’ve built a tool that confidently tells people to transmit on the output frequency. On a repeater. Which is both useless and antisocial.
The fix was to rename them at the data layer, the moment they come out of the CSV, and never let the repeater-centric framing reach the interface at all:
// The CSV's tx/rx are from the repeater's perspective. Rename to the
// user's radio perspective here so the confusing framing never leaks
// into the UI.
const rxMHzUser = repeaterTxMHz; // what the user's radio receives
const txMHzUser = repeaterRxMHz; // what the user's radio transmits
const shiftKHz = Math.round((txMHzUser - rxMHzUser) * 1000);
Three lines, one comment, and the entire class of bug is gone — because there’s no longer a variable in the codebase whose name could be misread. The popup says “Receive (listen)” and “Transmit”. Nothing anywhere says “tx” without saying whose.
It’s worth sanity-checking against something you know. GB3AA in Bristol comes out as receive 145.6625, transmit 145.0625, shift −600 kHz. That’s the standard UK 2m repeater split, which is a good sign the columns are the way round I think they are. If I’d had them backwards, the shift would have come out +600 kHz and looked subtly, plausibly wrong.
The general lesson: when you’re consuming someone else’s data, their field names encode their perspective. Rename at the boundary.
Repeaters aren’t one mode
My first data model had mode as a single value. That lasted until I looked at the actual rows.
ANALOG, DMR, DSTAR and FUSION are four independent yes/no columns, and plenty of repeaters have more than one set. 256 of them, as it turns out — about a third. GB3AG in Angus is FM and Fusion. GB3CU manages all four.
So modes is an array, and a repeater shows up whenever any of its modes is in your filter. For the marker colour I pick the first match in priority order — FM, then DMR, then D-STAR, then Fusion — so a dual-mode FM/Fusion repeater reads as FM on the map, which is the mode most people will care about. The popup lists all of them as badges, spelled out. FM. DMR. D-STAR. Fusion. Not A, B, or C.
18 rows have no mode flagged at all. I skip those and log why, rather than silently dropping them or inventing a mode for them.
Fetching it: a 403 and a User-Agent
The script fetched the CSV and got 403 Forbidden. Same URL in curl: 200 OK.
The obvious read is a User-Agent block — Node’s built-in fetch sends something identifiably robotic. So I set one, and took the chance to be a good citizen about it:
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; skipzone-repeater-map/1.0; +https://skipzone.co.uk)',
}
That says what I am and where to complain. It worked.
Then later in the same session it 403’d again, with that header in place. I went hunting for which of Node’s other default headers was upsetting it — and found the answer was none of them. By the time I tested, plain curl, curl with my User-Agent, and Node with User-Agent only were all returning 200.
It wasn’t a header problem at all. It was the server briefly getting fed up with me for fetching the same file a dozen times while developing, and saying so in the only way it can. Which is entirely fair.
The fix isn’t a cleverer header, it’s backing off:
const waitSec = 5 * (i + 1);
console.log(` ${res.status} ${res.statusText} — waiting ${waitSec}s before retry...`);
await new Promise((r) => setTimeout(r, waitSec * 1000));
Four attempts, increasing delay, then give up and tell me. It’s a small thing, but it’s the difference between a script that retries politely and one that treats someone else’s server as infinitely patient. Which brings me to the part I thought about hardest.
Why the data is a manual snapshot
The tempting design is a scheduled job that scrapes the CSV every night and auto-commits, or a Lambda that proxies the live file on every page load. I did neither. There’s an npm script:
npm run update-repeaters
I run it, read the diff, commit the JSON. That’s it.
Some of this is technical. The site is static — S3 and CloudFront, no server. Adding a backend to serve a file that changes a few times a month would be a lot of moving parts for very little freshness. The generated JSON is 786 records and gets bundled into the page’s JavaScript at build time, so there’s no extra request at all.
But mostly it’s about not being a nuisance. This is someone else’s freely published data, hosted at their expense, with no rate limits I’d be breaking and no terms saying I can’t. That’s exactly the situation where it’s easy to be inconsiderate without noticing. A proxy would mean every visitor to my page hits their server. A nightly scrape means 365 requests a year for data that changes far more slowly than that. Me running a command occasionally means a handful. It’s the polite amount of traffic, and it costs me nothing except remembering.
The script prints a summary every time, which is my only safety net given there’s no CI checking it:
Parsed 804 CSV rows.
Kept 786 / 804 rows.
Mode counts: FM 513, DMR 279, D-STAR 141, Fusion 210
Approximate positions (coarse locator, ~10 km): 202
If that suddenly says 50 instead of 786, the export format changed and I need to look before I commit.
The map
Leaflet with CartoDB Dark Matter tiles, same as the satellite tracker. It matches the site, it’s light, no API key.
786 circle markers, drawn plainly, no clustering library. I looked at adding one and decided against it: clustering earns its keep in the thousands, or when points pile up on top of each other like city-centre POIs. UK repeaters are spread across an entire country and rendered as vectors rather than DOM elements. It’s fine. Not adding a dependency is a feature.
Distance is Leaflet’s own map.distance(), which does haversine internally. I’d written a note to myself to implement great-circle distance and then found the map object already had it.
You can set your position four ways — the geolocation button, typing coordinates, searching a town or postcode via Nominatim, or just clicking the map. It’s kept in localStorage, so it’s still there next time. Below the map there’s a list of the nearest 15, sorted by distance, and clicking a row flies the map to that repeater and opens its popup.
Band and mode filters sit at the top, everything ticked by default. Both drive the markers and the nearest list from the same state, so they can’t disagree with each other. If you only care about 2m FM, two clicks gets you there.
What it won’t tell you
Some coordinates are only good to about 10 km. This is the caveat I’d most want to know as a user. The ETCC register repeaters with locators of varying precision, and the published coordinates inherit that. Most entries arrive with three decimal places — roughly 100 m. But 202 of the 786 come through rounded to one decimal place, which at UK latitudes is around 11 km of latitude and 7 km of longitude. Those markers are in the right town, not on the right hill.
Rather than quietly draw them the same as everything else, the map flags them: faded, dashed markers, a ~ in front of the distance, and a note in the popup. It’s the kind of thing that’s invisible until it matters, and then matters a lot — fine for “what can I hit from here”, useless for pointing a beam.
I flag them by measuring the decimal places on the raw coordinate string rather than by locator length, which catches 16 entries that have a long locator but coarse coordinates anyway.
There’s no status field in this export. I can’t tell you whether a repeater is currently on the air, off for maintenance, or has been off for two years. The ETCC track that; this particular CSV doesn’t carry it. So treat the map as “what’s registered”, not “what’s working right now”.
No keeper details or access notes, for the same reason — not in the file. If you need those, the ETCC site has them, and that’s where you should go.
The data is only as fresh as the last time I ran the script. The page tells you the date it was generated, so you can judge for yourself.
Try it
The map is live at skipzone.co.uk/tools/repeater-map. Set your position, untick everything except 2m and FM, and see what’s actually in range.
If you’ve not programmed a repeater into a radio before, the offsets and tones the map gives you will make a lot more sense alongside Getting on a VHF/UHF repeater, which covers what those numbers actually do.
And if you spot a repeater on the map that’s wrong, the fix belongs upstream at the ETCC — they maintain the data, I just draw it.
73 de MM7IUY