
Elections
Every election is a chance to change something in your country train your geoprocessing skills 🤣🤣🤣.
A few weeks ago in Poland, we had presidential elections (second round). What a wonderful opportunity to return to the mapping industry, even if just for a while.
We need to start from the basics – the results 🙂 They are published by PKW (the state body responsible for conducting elections). You can find them here. The file we are interested in is called Protokoły obwodowych komisji wyborczych w ponownym głosowaniu (results from the second round). I’m downloading the CSV. Let’s see what it contains (header + first row).
"TERYT Powiatu";"Powiat";"Województwo";"Liczba komisji";"Liczba uwzględnionych komisji";"Komisja otrzymała kart do głosowania";"Liczba wyborców uprawnionych do głosowania";"Nie wykorzystano kart do głosowania";"Liczba wyborców, którym wydano karty do głosowania w lokalu wyborczym";"Liczba wyborców, którym wysłano pakiety wyborcze";"Liczba wyborców, którym wydano karty do głosowania w lokalu wyborczym oraz w głosowaniu korespondencyjnym (łącznie)";"Liczba wyborców głosujących przez pełnomocnika";"Liczba wyborców głosujących na podstawie zaświadczenia o prawie do głosowania";"Liczba otrzymanych kopert zwrotnych";"Liczba kopert zwrotnych, w których nie było oświadczenia o osobistym i tajnym oddaniu głosu";"Liczba kopert zwrotnych, w których oświadczenie nie było podpisane";"Liczba kopert zwrotnych, w których nie było koperty na kartę do głosowania";"Liczba kopert zwrotnych, w których znajdowała się niezaklejona koperta na kartę do głosowania";"Liczba kopert na kartę do głosowania wrzuconych do urny";"Liczba kart wyjętych z urny";"W tym liczba kart wyjętych z kopert na kartę do głosowania";"Liczba kart nieważnych";"Liczba kart ważnych";"Liczba głosów nieważnych";"W tym z powodu postawienia znaku „X” obok nazwiska dwóch lub większej liczby kandydatów";"W tym z powodu niepostawienia znaku „X” obok nazwiska żadnego kandydata";"W tym z powodu postawienia znaku „X” wyłącznie obok skreślonego nazwiska kandydata";"Liczba głosów ważnych oddanych łącznie na wszystkich kandydatów";"NAWROCKI Karol Tadeusz";"TRZASKOWSKI Rafał Kazimierz"
20100;"bolesławiecki";"dolnośląskie";82;82;65042;65527;22049;42992;22;43014;71;771;21;1;0;0;0;20;43011;19;1;43010;347;178;169;0;42663;21717;20946
Lots of long names 😊 What we really need is Powiat (county) and the last 2 columns – names of the candidates. In each row, the value assigned to them is the total number of valid votes for the given candidate.
Ok, now let’s try to establish an action plan:
- extract the data we need from the CSV,
- for every row, use a geocoder to get the location of the given county,
- combine data from both sources to generate a geoJSON file,
- last (but not least), display the layer using maplibre-gl (❤️)
The first step – CSV. I decided to use csv-parser. For such a small file, we could also parse it directly. One important thing is to use the correct separator in the parser configuration (in this file, they use a semicolon). Also, the file itself contains a BOM (byte order mark) – an additional byte in the file which can cause trouble when parsing 😅 – we need to strip it.
const csv = require('csv-parser')
const csvFilePath = 'local-file-path'
fs.createReadStream(csvFilePath)
.pipe(
csv({
separator: ';',
mapHeaders: ({ header }) =>
header &&
header
.replace(/^\uFEFF/, '')
.replace(/"/g, '')
.trim()
})
)
.on('data', (row) => {
// use geocoder here
})
.on('end', () => {
// create geoJSON and save to file
})
This is how our parser looks at the moment.
Second step – geocoding. The OSM community has their own free geocoder called Nominatim. There are 2 types of jobs when geocoding:
- forward search – we pass a free query and as a response, we get a collection of geo-objects associated with the given phrase,
- reverse search – we pass geocoordinates to check what is at the given location. We can provide different map zoom levels to distinguish between street, city, country, or even continent.
What we know from the CSV is the name of each county, so we need to use forward search. Let’s run a dry test with cURL
on the first county from the list. As a search phrase, we’ll use powiat <name>
(where the name is taken from a row)
curl --location 'https://nominatim.openstreetmap.org/search?q=powiat%20boles%C5%82awiecki&format=jsonv2'
result
[
{
"place_id": 390634910,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "relation",
"osm_id": 451266,
"lat": "51.3216709",
"lon": "15.5210300",
"category": "boundary",
"type": "administrative",
"place_rank": 12,
"importance": 0.5046141852971268,
"addresstype": "county",
"name": "powiat bolesławiecki",
"display_name": "powiat bolesławiecki, województwo dolnośląskie, Polska",
"boundingbox": ["51.1122172", "51.5310265", "15.2057828", "15.8583528"]
}
]
Perfect! After doing some tests, I realized that in the CSV, county
can be represented by a city, and for a free search query, it doesn’t work well with the aforementioned phrase. So I decided to go with the following approach: when the value starts with a capital letter (meaning it is a city), we just use <name>
. For other cases, we’ll use powiat <name> województwo <voivodeship>
. Voivodeship is needed because there are counties with the same name, and by doing this, we’ll get a more accurate result. My simple geocoding function looks like this:
async function geocodeCounty(name, voivodeship) {
const isCity = /^[A-Z]/.test(name)
const query = isCity ? name : `powiat ${name} województwo ${voivodeship}`
const encodedQuery = encodeURIComponent(query)
const url = `https://nominatim.openstreetmap.org/search?q=${encodedQuery}&format=jsonv2`
try {
const res = await axios.get(url, {
headers: { 'User-Agent': 'Node.js script' }
})
const json = res.data
if (json.length > 0) {
return json
}
return null
} catch (e) {
throw e
}
}
Please be aware that according to Nominatim policies, the rate limit is 1 req / sec. We should respect that! We can add a simple throttle like so:
const [geocoded] = await Promise.all([
geocodeCounty(name, voivodeship),
new Promise((resolve) => setTimeout(resolve, 1000))
])
In the parser, I added logic which, for every row, saves county geojson data under a global map of counties, which are eventually saved to a new file. I will run the CSV parser again in order to join data from both sources. Having the same parser config, I’m adding an event handler:
.on('data', (row) => {
const name = row['Powiat']
const county = counties[name]?.[0]
if (!county) return
const { lat, lon } = county
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [+lon, +lat],
},
properties: {
powiat: name,
wojewodztwo: row['Województwo'],
numberOfAllowedBallots: +row['Liczba wyborców uprawnionych do głosowania'],
numberOfBallots: +row['Liczba kart wyjętych z urny'],
validBallots: +row['Liczba kart ważnych'],
Nawrocki: +row['NAWROCKI Karol Tadeusz'],
Trzaskowski: +row['TRZASKOWSKI Rafał Kazimierz'],
},
})
})
Please note that features
is defined before reading the file, and for every row, we are just pushing a new geoJSON feature. Also, we need to convert strings into numbers, as the parser returns them unprocessed. I’ve used the unary operator for this. Eventually, we dump data into a geojson file:
.on('end', () => {
const geojson = {
type: 'FeatureCollection',
features,
}
fs.writeFileSync(geojsonPath, JSON.stringify(geojson, null, 2))
})
Now we are ready to bring data to maplibre 🗺️🗺️🗺️! We want to have everything in a single html
file. For this reason, I’m importing libs from CDN, and
will be using vanilla-js
. I am not focusing on setting up maplibre; you can find more information about this in one of my previous posts. The question for now is how to bring data onto the map?
We want to display the result in a meaningful way, meaning:
- we want to see who won in a given county,
- how many votes were cast in a given county.
To do so, I’ll use a fill extrusion layer. For every county, we’ll present two bars (for 2 candidates), where the height reflects the percentage of votes for the candidate in that region. Also, the radius of the bars will be related to the total number of votes from this region. So for a small county, the bar should be thin, and for the capital – almost like a fat donut 🍩. There are more things to consider:
- currently, we store results per county, but in order to render 2 bars, we need to duplicate every feature, where every bar will be used to display the result for just one candidate,
- in order to use the
extrusion layer
, we have to use a polygon layer (currently we have just a point). Fortunately, the turf library provides a turf/circle utility function which does the job for us.
My code looks like below:
map.on('load', function () {
fetch('http://localhost:4321/county.geojson')
.then((response) => response.json())
.then((electionData) => {
const extrudedFeatures = []
electionData.features.forEach((feature) => {
const [lng, lat] = feature.geometry.coordinates
// 40_000 is magic number, empirically selected value
const extrusionRadius = feature.properties.validBallots / 40000
// move center of first candidate to the left
const nawrockiCenter = [lng - 0.03, lat]
const nawrockiCircle = turf.circle(nawrockiCenter, extrusionRadius, {
steps: 32,
units: 'kilometers'
})
nawrockiCircle.properties = {
...feature.properties,
_candidate: 'Nawrocki'
}
// move center of first candidate to the right
const trzaskowskiCenter = [lng + 0.03, lat]
const trzaskowskiCircle = turf.circle(
trzaskowskiCenter,
extrusionRadius,
{ steps: 32, units: 'kilometers' }
)
trzaskowskiCircle.properties = {
...feature.properties,
_candidate: 'Trzaskowski'
}
extrudedFeatures.push(nawrockiCircle, trzaskowskiCircle)
})
// new source with duplicated features
const extrudedCollection = {
type: 'FeatureCollection',
features: extrudedFeatures
}
map.addSource('powiaty-polygons', {
type: 'geojson',
data: extrudedCollection
})
map.addLayer({
id: 'nawrocki-extrude',
type: 'fill-extrusion',
source: 'powiaty-polygons',
filter: ['==', ['get', '_candidate'], 'Nawrocki'], // only bars from 1st candidate
paint: {
'fill-extrusion-color': '#FF1744',
'fill-extrusion-height': [
'interpolate',
['linear'],
['/', ['get', 'Nawrocki'], ['get', 'validBallots']],
0,
0,
1,
50000
],
'fill-extrusion-opacity': 0.8
}
})
map.addLayer({
id: 'trzaskowski-extrude',
type: 'fill-extrusion',
source: 'powiaty-polygons',
filter: ['==', ['get', '_candidate'], 'Trzaskowski'], // only bars from 2nd candidate
paint: {
'fill-extrusion-color': '#00FF94',
'fill-extrusion-height': [
'interpolate',
['linear'],
['/', ['get', 'Trzaskowski'], ['get', 'validBallots']],
0,
0,
1,
50000
],
'fill-extrusion-opacity': 0.8
}
})
})
})
I added comments to the difficult parts. In general, adding a new layer is super simple. First, we need to define a source
(in our case geoJSON), but
in most cases, it will be a vector tile endpoint. Once we have it, we define a new layer. The layer has to point to a source, have its own id, and have a type defined.
filter
and paint
properties allow us to customize the layer using so-called expressions. As you can see, I added two layers. With filter
, only features with criteria met are used within the layer. In the paint
property, we can use a custom color, and most importantly – define its height. First, we calculate the ratio, which is the result of the given candidate in the given county. This value is normalized into the range <1, 50000>
.
Now we have no choice but to open our HTML with e.g. Live Server.

For me, it looks quite decent, don’t you think?
Ok, that’s enough for today. As I mentioned at the very beginning, it was so good to jump into the mapping industry just for a while 💯. We walked through parsing CSV data, geocoding counties, generating GeoJSON, and building a compelling 3D visualization with fill extrusion layers. This approach can be adapted to many other datasets and is a great way to combine open data with modern web mapping tools. Also, I deployed all my work under this url – check it out !!!