Tidy Tuesday by Arun Koundinya Tidy Tuesday
  • Gallery
  • All weeks
  • About
  • Subscribe

How the world’s languages are connected through grammar

TidyTuesday 2026-11-03 · Grambank

R
interactive
maps
An interactive map of Grambank, in R and Observable: pick any of about 2,000 languages and see its 10 closest grammatical matches anywhere in the world, or the hub languages that turn up most often in others’ top-5 lists.
Author

Arun Koundinya Parasa

Published

November 10, 2026

Share
TidyTuesday week3 Nov 2026
DatasetTidyTuesday 2026-11-03 readme ↗
Built withR · tidyverse; Observable Plot
ChartsInteractive map, k-nearest-neighbour network

Pick a language from the list, or click any dot on the map. The map highlights only that language’s 10 closest grammatical matches, numbered from most to least similar and coloured by language family. Switch to Top 10 hubs to see the languages that show up most often in other languages’ top-5 lists, each joined to its own 5 closest matches.

Plot = require("@observablehq/plot@0.6.16")
land = d3.json("https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/land-110m.json")
  .then(w => topojson.feature(w, w.objects.land))
L = transpose(nodes)
E = transpose(links)
byId = new Map(L.map(d => [d.id, d]))
famColours = ["#4E79A7","#F28E2B","#E15759","#76B7B2","#59A14F",
              "#EDC948","#B07AA1","#FF9DA7","#9D9D9D"]
viewof sel = Inputs.input("hind1269")      // the selected language (shared state)
viewof view = Inputs.radio(["Selected language", "Top 10 hubs"],
                           {value: "Selected language", label: "Show"})
viewof pick = Inputs.bind(
  Inputs.select(
    new Map(L.map(d => [`${d.name} (${d.family})`, d.id])
             .sort((a, b) => a[0].localeCompare(b[0]))),
    {label: "Language", width: 360, disabled: view !== "Selected language"}),
  viewof sel)
viewof k = Inputs.range([5, 25], {step: 1, value: 10, label: "Matches to show",
                                  disabled: view !== "Selected language"})
viewof zoom = Inputs.toggle({label: "Zoom to highlighted", value: true})
viewof showNet = Inputs.toggle({label: "Show full network (busy!)", value: false})
similarity = (a, b) => {
  let n = 0, m = 0;
  for (let i = 0; i < a.length; i++) {
    if (a[i] !== "." && b[i] !== ".") { n++; if (a[i] === b[i]) m++; }
  }
  return n >= 100 ? m / n : NaN;
}
closest = (lang, n) => L.filter(d => d.id !== lang.id)
  .map(d => ({...d, sim: similarity(lang.f, d.f)}))
  .filter(d => !isNaN(d.sim))
  .sort((a, b) => b.sim - a.sim).slice(0, n)

me = byId.get(sel)
data = L.map(d => ({...d, sim: d.id === sel ? NaN : similarity(me.f, d.f)}))
nbrs = closest(me, k).map((d, i) => ({...d, rank: i + 1}))

// the 10 languages that appear most often in other languages' top 5
hubs = L.slice().sort((a, b) => b.hub - a.hub).slice(0, 10)
  .map((d, i) => ({...d, rank: i + 1}))
// each hub joined to its own 5 closest matches (50 lines, not hundreds)
hubLinks = hubs.flatMap(h => closest(h, 5).map(d =>
  ({lon1: h.lon, lat1: h.lat, lon2: d.lon, lat2: d.lat})))
{
  const isHub = view === "Top 10 hubs";
  const focus = isHub ? hubs : nbrs;
  const sMin = d3.min(nbrs, d => d.sim), sMax = d3.max(nbrs, d => d.sim);
  const zoomPts = isHub ? focus : [me, ...focus];
  const xy = {x: "lon", y: "lat"};

  const W = Math.min(width, 1200);
  const fig = Plot.plot({
    width: W, height: Math.round(W * 0.5),
    style: {fontSize: "12px", background: "transparent"},
    projection: zoom
      ? {type: "equal-earth", inset: 40,
         domain: {type: "MultiPoint", coordinates: zoomPts.map(d => [d.lon, d.lat])}}
      : "equal-earth",
    color: {type: "categorical", domain: fam_domain, range: famColours,
            legend: true, label: "Language family"},
    marks: [
      Plot.sphere({fill: "#E8F0F3", stroke: "#E2DBCD", clip: "frame"}),
      Plot.geo(land, {fill: "#E6DFD2", stroke: "#FFFFFF", strokeWidth: 0.4, clip: "frame"}),
      showNet ? Plot.link(E, {x1: "lon1", y1: "lat1", x2: "lon2", y2: "lat2",
                               stroke: "#333", strokeOpacity: 0.05, strokeWidth: 0.5,
                               clip: "frame"}) : null,
      // every language as a faint grey dot, for context only
      Plot.dot(L, {...xy, r: 1.6, fill: "#A9A59D", fillOpacity: 0.7, clip: "frame"}),
      // the connections
      isHub
        ? Plot.link(hubLinks, {x1: "lon1", y1: "lat1", x2: "lon2", y2: "lat2",
                               stroke: "#0B7A5C", strokeOpacity: 0.6, strokeWidth: 1,
                               clip: "frame"})
        : Plot.link(nbrs, {x1: () => me.lon, y1: () => me.lat, x2: "lon", y2: "lat",
                           stroke: "#0B7A5C", strokeOpacity: 0.8, clip: "frame",
                           strokeWidth: d => 0.8 + 2.5 * (d.sim - sMin) / (sMax - sMin || 1)}),
      // the highlighted languages: coloured by family, numbered by rank
      Plot.dot(focus, {...xy, r: 10, fill: "fam", stroke: "white", strokeWidth: 1.5}),
      Plot.text(focus, {...xy, text: "rank", fill: "white", stroke: "#1b2a3a",
                        strokeWidth: 2.5, fontSize: 11, fontWeight: "bold"}),
      // name the three closest matches (or the three biggest hubs) next to their dots
      Plot.text(focus.filter(d => d.rank <= 3), {...xy, text: "name", dx: 14, textAnchor: "start",
                        fontSize: 12, fontWeight: "bold", fill: "#1b2a3a", stroke: "white", strokeWidth: 4}),
      // the selected language
      isHub ? null : Plot.dot([me], {...xy, r: 10, fill: "#1b2a3a", stroke: "white", strokeWidth: 2.5}),
      isHub ? null : Plot.text([me], {...xy, text: "name", dy: -20, fontSize: 15,
                                      fontWeight: "bold", fill: "#1b2a3a", stroke: "white", strokeWidth: 5}),
      Plot.tip(data, Plot.pointer({
        ...xy,
        title: d => `${d.name}\n${d.family} · ${d.area}\n` +
                    (isHub ? "" : `Similarity to ${me.name}: ${isNaN(d.sim) ? "n/a" : d.sim.toFixed(2)}\n`) +
                    `In the top 5 of ${d.hub} languages\n(click to select)`
      }))
    ]
  });

  // clicking any dot selects that language (and switches to its view)
  fig.addEventListener("click", () => {
    const d = fig.value;
    if (!d) return;
    if (d.id !== sel) {
      viewof sel.value = d.id;
      viewof sel.dispatchEvent(new Event("input", {bubbles: true}));
    }
    if (view !== "Selected language") {
      viewof view.value = "Selected language";
      viewof view.dispatchEvent(new Event("input", {bubbles: true}));
    }
  });
  return fig;
}

Closest matches to the selected language

Inputs.table(nbrs.map(d => ({"#": d.rank, Language: d.name, Family: d.family,
                             Similarity: d.sim})),
  {format: {Similarity: x => x.toFixed(3)}, rows: 25, height: 360, layout: "auto"})

Top 10 hubs (most connected languages)

Inputs.table(hubs.map(d => ({"#": d.rank, Language: d.name, Family: d.family,
                             "In top 5 of": d.hub})),
  {rows: 10, height: 360, layout: "auto"})

Similarity = the share of Grambank features coded for both languages on which they give the same answer (at least 100 shared features required). Hub score = the number of languages that list this one among their 5 closest. Data: Skirgård et al. (2023), Grambank, via TidyTuesday.

Know someone who'd enjoy this?

Share this chart

WhatsAppLinkedInXFacebookTelegramEmail

One week down

Get the next chart in your inbox

Or browse the other charts, and leave a comment below.

← Back to the gallery

© 2023 – 2026 Arun Koundinya Parasa · Also: Canvass And Analyze · AI Basics with AK · Deep Learning

 

Built with 💚 and Quarto. Data from #TidyTuesday.