Flavor Lines — VOVA.TODAY

VOVA.TODAY

17 min read Original article ↗

The question

Who really draws these flavor lines — mountains and rivers, or language?

OpenStreetMap lists more than two million places to eat across nearly the entire world. I look at each name, and whenever it contains the name of a dish, I colour the ground around it to match. The continents colour themselves in: solid ‘plov’ here, ‘biryani’ a few hundred kilometres away, then ‘pilaf’ farther on. The edges between those colours are lines that no one has drawn, yet everyone roughly understands.

Key findings

  1. 01

    In the Hindu Kush, plov ends and biryani begins.

  2. 02

    Most flavor lines follow culture, not terrain — though the two sometimes coincide.

  3. 03

    Döner, shawarma and gyros all mean ‘turning’, each in a different language.

  4. 04

    kebap means döner in Germany and skewered meat in Turkey: one word, two dishes.

Why

I came across a post on Threads:

Strictly speaking, fried rice, plov and paella are different dishes, though they are related. But the observation holds: similar dishes have territories of their own, and one name gives way to another at the edge. No one had traced those edges. I wanted to measure them and put them on a map.

MythBusters for data memes: take a neat claim from the internet and test it against real data.

How it was made

A restaurant sign tells you exactly one thing: which word people use for a dish at that spot. It cannot tell you why this is plov country while biryani starts a few hundred kilometres away (though it leaves room for guesses). So the map answers ‘WHERE?’ and says nothing about ‘WHY?’.

Everything below came from the data itself. The coverage is uneven: OpenStreetMap is far less popular in some places than others. The safest findings are the ones that survive that bias.

The main finding held up through every check: almost every flavor line follows a LANGUAGE border (or a cultural one, if you prefer), not the terrain. The clearest example is the Slavic dumpling wall: moving east, pierogi give way to varenyky, then pelmeni — right along the language borders.

A slice of the ‘Slavic wall’: pierogi → varenyky → pelmeni
A slice of the ‘Slavic wall’: pierogi → varenyky → pelmeni

Linguists call this an isogloss, so the project is in good company. Back in the 1880s, Georg Wenker sent 40 sentences to schools across the German Empire and drew the first dialect boundaries. He found something odd: the lines followed neither the Rhine nor the mountains. Instead, they fanned out along old political borders that everyone had forgotten.

Wenker’s dialect atlas near Stettin: the ‘ōan / ōrn / urn’ isoglosses fan out along old borders — a direct ancestor of Flavor Lines. © Forschungszentrum Deutscher Sprachatlas, CC BY-SA 4.0.

Wenker’s dialect atlas near Stettin: the ‘ōan / ōrn / urn’ isoglosses fan out along old borders — a direct ancestor of Flavor Lines. © Forschungszentrum Deutscher Sprachatlas, CC BY-SA 4.0.

The ‘Rhenish fan’: dialect isoglosses follow forgotten political borders, not the Rhine. © Hans Erren, CC BY-SA 3.0.

The ‘Rhenish fan’: dialect isoglosses follow forgotten political borders, not the Rhine. © Hans Erren, CC BY-SA 3.0.

There is a nice precedent: two of the most viral maps in internet history were maps of food names — America’s ‘soda vs pop vs coke’ map and Joshua Katz’s sandwich map (2013). Both cover one country, though, and both rely on surveys. Flavor Lines takes the same idea worldwide, using real signs.

One seam stands out: plov and biryani split along the Hindu Kush — perhaps the only place where a flavor line runs beside a mountain range. Even here, though, it is the people on either side who name the dish, not the ridge. The mountains hampered cultural exchange, so plov and biryani never blurred into each other.

The Hindu Kush: the plov–biryani line near 70° E
The Hindu Kush: the plov–biryani line near 70° E

In China, fried rice is just lunch: the sign carries the restaurant’s name, not 炒饭. Out of 234,000 Chinese restaurants, those characters appear on just 129 — not 129,000, simply 129. The export layer reverses the pattern: Greece itself has only 9% of the world’s gyros shops, while the US has 38%; Germany has 51% of all places with döner in the name; nearly 90% of places named after khinkali are in Russian cities, and only 4% are in Georgia.

At home, a dish is just lunch. Abroad, its name on the sign becomes a flag.

The post-Soviet world has 97% of all places named ‘Shashlik’; Japan has 84% of the yakitori places, Indonesia 85% of the satay places, and Turkey 86% of the köfte places. My favourite discovery: döner, shawarma and gyros all mean ‘turning’. Turkish dönmek means ‘to turn’, Arabic shawarma (via çevirme) means ‘turning’, and Greek γύρος means ‘a turn’ — the same Ottoman spit, named ‘the thing that turns’ independently in three languages.

A few more from the same family: pelmeni is not a Slavic word but the Finno-Ugric ‘ear-bread’ (Komi/Udmurt pelnyan). Paella means ‘pan’, from the Latin patella: in the ‘plov → paella’ meme, the words are strangers; they merely rhyme. Souvlaki goes back to the Latin for ‘awl’. A single Persian polow travelled out into plov, pilav, pulao, pilaf and pelau.

The idea is shared; every language coins its own word.

Some dishes have more restaurants; others have more territory. An empty cell goes to whichever dish dominates the nearest cluster of points. Noodles make a fun case: ramen has the most restaurants worldwide (nearly 9,700), but pasta owns the map, covering 54% of the land.

The first version was built from off-the-shelf parts: Leaflet, a basemap of CARTO tiles, and a Pixi heatmap on top. I removed the tiles and kept the dark background — which also wiped national borders from the map, since CARTO bakes them straight into the image.

The first prototype: plov, biryani, fried rice and paella
The first prototype: plov, biryani, fried rice and paella
The only borders this map needs are flavor lines.

Then I realised Leaflet was dead weight: a full video renderer was running underneath while I steered it through a slow layer of DOM transforms. I rewrote the renderer from scratch in plain WebGL2 — my own shaders, camera and projection, plus three hundred lines of touch gestures. Pixi, which drew the points, went next. In for a penny.

−462 KBremoved from the bundle with Leaflet + Pixi

The data comes from OpenStreetMap, where volunteers have already mapped the world’s restaurants and made the data open. At first I pulled it through the Overpass API, one dish at a time. Once the list of dishes grew, I decided to download the entire planet dump instead.

87 GBthe full OSM planet dump

DuckDB reads it straight from the file, without importing it into a database. One query scans the planet and keeps only places that serve food:

sql
-- points: the node is its own coordinate
SELECT 'n'||id AS id, lat, lon, tags['name'] AS name, tags
FROM ST_ReadOSM('planet.osm.pbf')
WHERE kind = 'node'
  AND tags['amenity'] IN ('restaurant', 'fast_food', 'cafe', 'food_court')
UNION ALL
-- polygons: take the coordinate of the outline's first node
SELECT 'w'||w.id, n.lat, n.lon, w.tags['name'], w.tags
FROM ST_ReadOSM('planet.osm.pbf') w
JOIN ST_ReadOSM('planet.osm.pbf') n ON n.id = w.refs[1]
WHERE w.kind = 'way'
  AND w.tags['amenity'] IN ('restaurant', 'fast_food', 'cafe', 'food_court')

2.88 Mplaces in the result, ~81 MB

One wrinkle: in OSM a place is either a point or a building outline. Outlines are almost one in five (fast food and food courts skew higher), so ‘points only’ would have quietly dropped half a million places. So I take the outlines too: their coordinate is the first node of the outline — a corner of the building rather than its geometric centre, but at the 0.1° clustering step that difference vanishes. Multipolygon relations I still skip: there are few of them, and the geometry is a chore to assemble.

Dishes are recognised with an ordinary dictionary of regular expressions, in two passes for each one. The broad pass grabs anything it can so nothing slips through: plov also pulls in Plava Laguna and the Plowshares coffee shop, while one search for manti returned 478 places — including Romantic, Mantis and Manta. The narrow pass checks word boundaries and throws those out, leaving 154 real manti places. Fuzzy search would not help here: it would drown in the same false positives, with no narrow pass to clean them up. These are the spellings the map knows — dozens for every dish:

Rice

Plov
plovploffplofplowpalov

Pulao
pulaopullaopulavpolaopolaw

Biryani
biryanibiriyanibirianibriyanibriani

Paella
paellapaëllapaelyaпаэльяпаэлла

Risotto
risottorissottorisotorizottoризотто

Dumplings

Pelmeni
pelmenipelmenpelmeshpeljmenipielmieni

Vareniki
varenikivarenykyvarenykвареникваренич

Pierogi
pierogipierogperogipyrohypierozki

Manti
mantimantımantymantuманты

Buuz
позыпознаябуузыбуузбуузная

Shawarma / döner

Shawarma
shawarmashawermashaurmashoarmashwarma

Döner
dönerdonerdoenerdonairdönerci

Gyros
gyrosgyroyirosyeerosγύρος

Kebab
kebabkebapкебабκεμπάπ케밥

Skewered meat

Shashlik
shashlikshashlykshaslikschaschlikšašlik

Shish kebab
shish kebabshish kabobseekh kebabsis kebapşiş

Souvlaki
souvlakisouvlakiasuvlakisouvlaσουβλάκι

Satay
sataysatésatehsate ayam沙嗲

Yakitori
yakitorikushiyaki焼き鳥焼鳥やきとり

Noodles

Ramen
ramenラーメンらーめん拉麺中華そば

Pho
phởpho bopho gaфо боフォー

Udon
udonうどんウドン饂飩烏冬

Soba
sobasobaya蕎麦そば屋ソバ

Lagman
lagmanlaghmanлагманлағманلەغمەن

The sixth map covers cuisines and works differently. Instead of a dish name, it reads the restaurant’s own cuisine tag. I fold the tags into 29 national cuisines: sushi and ramen count as Japanese, tapas as Spanish. Global fast food (pizza, burger) and vague tags (asian) are left out.

Looking through the finished dictionary, I found a systematic error of my own. The spelling coverage varied wildly: fried rice appeared in twelve writing systems, while pelmeni had only Latin and Cyrillic. In places, the map was measuring the richness of my dictionary, not the popularity of the dish. A pelmeni shop in Thailand with a Thai sign was invisible to it.

The blunt solution: pull the largest possible set of names for every dish from Wikidata — labels and aliases in every language — and layer it over the hand-written regexes. The next run found 10.5% more matches.

+9.4%matches after Wikidata: 48,661 → 53,217

The interesting part was WHERE coverage grew. Soba jumped 257%: most shops write そば in hiragana, while the old dictionary knew only the kanji. Satay rose 118%. Döner lit up all of Australia. The bias was Asian: the old search struggled most with Thai script, Chinese characters, Japanese kana, Korean hangul and Devanagari. The gain was smaller in Africa and Latin America, where it already found most names written in the Latin alphabet.

A larger dictionary improved coverage but brought false positives: a dish name in one language may be an ordinary word in another. Two tricks restored precision: Unicode-aware word boundaries (JavaScript’s \b understands ASCII only) and a small blocklist for Wikidata’s semantic drift: кофта ← кофта, Russian for ‘sweater’; momo ← モモ, Japanese for ‘peach’. A leak check found a bigger one: the entire ‘growth’ in pierogi came from pirogi — the Russian word for baked pies.

60%of kebab matches were bare ‘kebap’ (döner in Germany, skewers in Turkey)

The prettiest fight was over kebab. The blue ‘skewered kebab’ zone suddenly flooded Germany. But German ‘Kebap’ means döner, not an Iranian skewer. The same word points to opposite dishes, and the sign alone cannot tell them apart. The spelling held the key: kebab (with an e) is a döner wrap; kabab (with an a) is meat on a skewer; kebap (with a p) splits fifty-fifty, so I had to drop it entirely.

A blank is better than German döner dressed up as an Iranian skewer.

Two more cases of the same kind. In Japan, 餃子 is read ‘gyoza’; in China and Taiwan it is ‘jiaozi’. Ignoring language turned all of Japan into jiaozi territory. I split the matches by context: 餃子 counts as gyoza by default, but Chinese context markers take it out of that category. That keeps Taiwanese Shandong dumpling shops where they belong, as jiaozi.

点心 has a different problem: dim sum is not one dish but a Cantonese style of eating that covers dozens of items. I gave it a category of its own.

1114gyoza after disambiguating 餃子 (up from 598)

A general pattern emerged from all this: a name on a sign lies in three ways. A shared word with different regional meanings (kebab) stays off the map. A shared character with different readings (餃子) goes to the dominant reading, minus any minority markers. A general name in place of a specific dish (点心) gets a territory of its own. The rule is always the same: classify what the sign says. Recipe and country do not count.

After the rebuild, nearly every shift strengthened the argument and none weakened it: shashlik became far more sharply regional, 68% → 97%.

Next, the scatter of points has to become regions. I divide the land into a fine grid. For every cell, I find the nearest restaurant, gather the restaurants around it (within roughly one degree), and count which dish is most common. That dish sets the cell’s colour. One lonely restaurant cannot claim an entire region; its neighbours decide. In essence, it is a Voronoi diagram of restaurants.

Territories: Voronoi cells spread from the centre to the edges, with neighbouring waves blending softly.

I also spent time on fallbacks. Generic signs such as ‘Noodle House’ or plain ‘Kebab’ drown out anything specific — in Thailand, anonymous ‘noodle’ used to overpower pad thai. The map still recognises generic words but does not let them colour the land, so Thailand reads as pad thai again.

Every dish card has a photo from Wikimedia Commons, with its author and licence. The automatic picker sometimes returned a collage or an old painting instead of the dish, so I reviewed every image on a contact sheet — one long page of thumbnails made with ImageMagick — and replaced the bad ones by hand.

A small detail that echoes the problem above: jiaozi and gyoza use the same photograph, because Wikipedia treats them as one article.

Building the map layer by layer: terrain, detail, points, Voronoi, bloom.

Today, a fragment shader computes the zones. My first plan was different: calculate the Voronoi geometry once as vectors, then simply redraw it. But d3-delaunay draws boundaries in its own metric, with no regard for the map projection, and the zones came out jagged and angular. We never got along. (Fine, I couldn’t crack it.)

The antimeridian — the 180° date line — brought its own trouble. A naive map measured each hemisphere separately: a point just west of the line thought its nearest restaurant was 2,000 kilometres away, though another stood only 300 kilometres away ON THE OTHER SIDE. The fix is simple: duplicate every seed into copies of the world on the left and right, making longitude endless and seamless.

I draw the points with instancing: one square, three buffers per instance, one draw call for everything. They live in my own binary format, PB01, replacing about 1.76 MB of JSON with zigzag-varint coordinate deltas and one ‘dish’ byte per point. It decodes roughly ten times faster than JSON.parse().

~260 KBthe entire point layer, PB01 format (gzip)

My favourite bug. After the move to binary, dense regions started forming an ugly regular grid. The decoded data was identical byte for byte; there was no grid in the numbers. The cause: the renderer thins points to one per screen cell, taking the first one in array order. The binary data is sorted spatially for compression, so every surviving point landed in the same corner of its cell. The fix: the decoder deterministically shuffles the points before rendering.

Array order was an invisible but important part of the rendering pipeline.

The Voronoi pass has already divided the map into dish territories. Inside each one, I vary the glow: bright near restaurant points, dim far away. This shows where the data is dense and where the map is working almost blind. At first I summed the contribution of every point and tried about a dozen variants. Tight Gaussian blobs and cones made splotches; widen the Gaussian for smoothness and it loses its peak, then fades to nothing. Summation was the wrong physics. A distance field worked: brightness comes not from a sum but from the distance to the nearest point. Bright at the point, dim far away, smooth and splotch-free. The GPU computes it with Jump Flooding — linear, with the same cost at every zoom level.

This is neither a tile set nor an external basemap. It is one baked height texture, shaded pixel by pixel on screen. I baked the elevations from the open ETOPO 2022 DEM (NOAA). The shader calculates its own hillshade from the slope between neighbouring texels and a ‘sun’ in the north-west.

The baked height texture: the world’s relief in one black-and-white map.
The baked height texture: the world’s relief in one black-and-white map.

Then came the saga. A ready-made hillshade turns blurry as you zoom — detail has to be computed at screen resolution, not sampled from an image. I wanted all the world’s land to fit in 500 KB, so the missing detail had to be invented on the fly: fractal noise (ridged multifractal, fBm), warped along the real slope (domain warp) so the synthetic ridges follow the actual terrain.

I seriously considered Wave Function Collapse, by the way. I dropped it because it is discrete, runs on the CPU and works at a fixed resolution. It cannot be a function evaluated per pixel.

Left: detail shader on. Right: off.

At one point I squeezed the terrain down to 100 KB. It looked wonderful except at the coast: islands vanished and Japan became a smooth banana. The final version is 500 KB, with its coastlines intact.

One more trap: numeric precision. On some phones, the terrain broke into a grid of squares at high zoom, while desktop looked clean. ‘Clean on desktop’ was the clue. Desktop fragment shaders use true 32-bit precision (highp); some phones use mediump (fp16), where the coordinate change from one pixel to the next falls below the number format’s precision. The fix: detect GPU precision at startup and disable only the excess detail on affected devices.

The secret to 60 fps is one rule: while the map is moving, recompute NOTHING. Everything is anchored to normalised Mercator coordinates, so panning and zooming reduce to reprojection. The expensive zone pass runs only after the zoom settles. Hovering once burned 80% CPU over Europe — it checked every restaurant on every frame — so I moved hit testing to a separate 2D canvas.

80% → 15%CPU while hovering over Europe: before → after

The visual language took a crooked path. Early versions were austere — pale colours, strict typography — and the whole thing looked a bit dreary. The turn came while I was tweaking variables and saw that the map resembled a DEFCON screen, the sort of battle display you would find in a command centre. That became the reference.

The map is the core, but I built two tools around it so people would actually use it. Story mode: as you scroll, the map follows the story, changing its camera, dishes and layers. Autopilot guides the reader at first, then hands over the controls. I gave the maps punning titles: ‘War and Peas’, ‘Axis of Kebab’, ‘Where’s Wonton?’, ‘Use Your Noodle’.

The card maker. Pick a piece of the map, choose a format (square, 4:5, 9:16 story, or 16:9), edit the headline, and out comes a finished PNG. The point is to make the reader a co-author: turn the map into a tool for building their own infographic.

Data & caveats

  • OpenStreetMap — all restaurant data (© OpenStreetMap contributors, ODbL).
  • The OSM planet dump (~87 GB), from which DuckDB extracted 2.35 million places.
  • Wikidata — dish names (labels and aliases in every language), layered over a hand-written regex dictionary.
  • Wikimedia Commons — dish photographs, each credited to its author with its licence (CC BY / CC BY-SA / CC0 / PD).
  • ETOPO 2022 (NOAA) — source elevation data for the terrain.

Coverage around the world is extremely uneven: half of all points are in Western Europe and East Asia, though together they make up only about 5% of the world’s land. Tropical Africa accounts for a fraction of one percent. The safest patterns are therefore the ones that survive this bias.
The map sees only what appears in a restaurant’s name. If a dish is not on the sign, it does not exist here. Boundaries between words are never perfectly sharp either; every isogloss blurs at the edges. I left out ambiguous spellings such as kebap, so some real restaurants are missing.

What this work demonstrates

  • GPU rendering
  • Custom shaders
  • Planet-scale geodata
  • Multilingual NLP
  • Cartography
  • Storytelling
  • Product design

Built with

WebGL2Nuxt 3Vue 3DuckDBWikidataCloudflare PagesPlaywrightPython

This format can be adapted for a report, research project or editorial feature built around your data.