Telegram channels have no location field that anyone fills in. If you want a map of local channels, you have names, usernames, tags and descriptions, and a gazetteer. We built exactly that for Ukraine: 4,076 channels placed in 277 cities and towns, with no hand-labelled training set. This post is about the part that took the time, which was not matching city names but throwing out the false matches.
The result is the map of Telegram channels in Ukraine. Opening it downloads about 234 KB of static files and makes zero API requests.
The inputs
- A gazetteer. GeoNames for places with every alternate spelling, in Ukrainian, Russian, English and a few other languages. Natural Earth admin-1 regions for the country outline, by ISO code.
-
Channel text. Title, username, tags and description, plus the channel's own
geofield where we have one. - Embeddings. Each channel already had a meaning vector from a multilingual model (we built it for semantic search). The geo map only covers channels with a vector, which is a subset of everything we index.
A city is any GeoNames place above a population threshold, plus every regional and district centre regardless of size. District names inside a big city are merged into that city.
Step 1: generate candidates, generously
For every channel we look for every spelling of every place in its text. That includes case endings (Kharkiv, Kharkova, Kharkivskyi), adjectives with the -sk and -skyi endings, and Latin transliterations in usernames. This step is intentionally loose: it should miss nothing, and it produces a lot of nonsense.
Step 2: reject the nonsense
Three classes of false match did most of the damage. Each is a normal word or name that happens to equal a place name:
-
humanmatches Uman, a city, through a Cyrillic transliteration. -
український("Ukrainian") matches Ukrainsk, a small town. -
Сергей, a common first name, matches Sergo, a town.
No list of stop words would have kept up, so the filter works on statistics:
- Mention frequency versus population. If a spelling is mentioned in far more channels than a place of that population could plausibly have local channels, it is a common word. Spellings that fail this test are accepted only on an exact match in the title or username, with high classifier confidence and a sign that the channel is Ukrainian.
-
Namesakes in neighbouring countries. Using the list of cities above 15,000, a place is flagged as a namesake if a city of the same name abroad is at least 0.3 times its size. Those candidates need a stronger "this channel is Ukrainian" signal: language, the
geofield, or Ukrainian-only letters such as the ones in "і" and "ї". - Two classifiers on the embeddings. One predicts "this channel is about a specific place" (positives: channels with the place in the title; negatives: random channels). The other predicts "this channel is Ukrainian" against channels from neighbouring countries. Both are trained on labels we already have, so no manual labelling is involved.
Every rejection has a reason code: word, multiple matches, foreign, ambiguous, semantic, category. The build prints counts per reason, and a report per city. We looked at the summaries, not at every channel. The usual red flags were a small town with an implausibly large number of channels, a city from a neighbouring country, a person's name, and national media that fell into the capital without ever mentioning it.
Edge cases that needed config, not code
-
Regions that contain contested territory. The "own country" classifier is switched off there, because the channels often carry another country's
geoand language. Borders follow ISO codes, which is why Crimea and Sevastopol are counted in this map. - Whole-region channels. A channel about Crimea in general should not become a channel in Simferopol. It gets its own circle.
- Old and colloquial names. Aliases are matched only exactly or with a case ending.
- Short names. Names of four letters or fewer in small towns are skipped, since they collide with ordinary words.
Making the page cheap
The build writes tiles with channel names in a single small file. For Ukraine that is one tile of 88 KB gzipped, so the page needs no API call to open. A channel card costs one request, on click. Tile paths include a version and are served immutable, so a new layout is a new folder, not an overwrite.
Rendering had one lesson. Drawing text outlines and icon rings for every label on every frame cost 18 ms of JavaScript per frame when zoomed into Kyiv. Rendering each label once into an ImageBitmap and drawing from a cache cut that to 3.9 ms.
Doing it again for other countries
The same pipeline, driven by a per-country config file, now covers Uzbekistan (3,784 channels in 135 cities) and Iran (5,253 channels in 333 cities). What changed was the config: morphology (Uzbek Latin and Cyrillic endings, Persian plural suffixes), accepted scripts, and the list of generic words like "province" and "shahr" to strip from names.
The garbage in alternate names grew with each country: ordinal province names, Soviet-era city names, common words and personal names, even neighbouring cities' names. All of that goes to a per-country exclusion list. For Iran we also had to merge Arabic and Persian letter variants and turn zero-width non-joiners into spaces before matching. The map of Iran is the largest of the three.
What we would tell someone starting this
Generate candidates loosely and filter on rates, not on lists. Compare how often a spelling is mentioned with how many local channels its population could support, and check the top-20 suspects in every build. Keep a per-country config file for everything that is a judgment call. And measure the frame before you optimise the renderer.


Top comments (0)