topojson = require("topojson-client@3")
world_topo = fetch(
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
).then(r => r.json())
mapW = width || 960
mapH = Math.round(mapW * 0.55)
proj = d3.geoNaturalEarth1()
.scale(mapW / 6.3)
.translate([mapW / 2, mapH / 2])
geoPath = d3.geoPath(proj)
dpr = Math.min(window.devicePixelRatio || 1, 1.5)
makeCanvas = () => {
const canvas = htl.html`<canvas width="${mapW * dpr}" height="${mapH * dpr}">`
canvas.style.cssText = `width:${mapW}px;height:${mapH}px;position:absolute;top:0;left:0;pointer-events:none`
const ctx = canvas.getContext("2d")
ctx.scale(dpr, dpr)
return {canvas, ctx}
}
gc = transpose(gc_data)
eps = transpose(endpoint_data)
allCities = transpose(all_cities)
// ---------- stable canvas layer: all cities, resettable draw animation ----------
cityCanvas = {
const {canvas, ctx} = makeCanvas()
let timer = null
canvas._clear = () => {
if (timer) { clearTimeout(timer); timer = null }
ctx.clearRect(0, 0, mapW, mapH)
canvas._active = false
}
canvas._startDraw = () => {
if (canvas._active) return
canvas._active = true
ctx.fillStyle = "rgba(255,192,76,0.5)"
const total = allCities.length
const delay = 3000 / (total / 8)
let i = 0
const draw = () => {
const end = Math.min(i + 8, total)
for (; i < end; i++) {
const p = proj([allCities[i].lng, allCities[i].lat])
if (!p) continue
ctx.beginPath()
ctx.arc(p[0], p[1], 1.5, 0, Math.PI * 2)
ctx.fill()
}
if (i < total) timer = setTimeout(draw, delay)
else timer = null
}
draw()
}
return canvas
}
// ---------- stable land layer drawn once to canvas ----------
landCanvas = {
const {canvas, ctx} = makeCanvas()
const land = topojson.feature(world_topo, world_topo.objects.countries)
const canvasPath = d3.geoPath(proj, ctx)
land.features.filter(f => +f.id !== 10).forEach(f => {
ctx.beginPath()
canvasPath(f)
ctx.fillStyle = "#0d3d3d"
ctx.fill()
ctx.strokeStyle = "#061212"
ctx.lineWidth = 0.3
ctx.stroke()
})
return canvas
}
// ---------- reactive SVG layer: connections + Rio pulse only ----------
allFeatures = {
const byId = d3.group(gc, d => d.pair_id)
return [...byId.entries()]
.sort((a, b) => a[0] - b[0])
.map(([id, pts]) => ({
type: "Feature",
geometry: { type: "LineString", coordinates: pts.map(p => [p.lon, p.lat]) },
properties: { pair_id: id, dist_km: pts[0].dist_km, continent: pts[0].continent }
}))
}
// Continent reveal order per trigger.
// null = show all; [] = show none (Rio only).
continentSets = [
[], // 0: title
[], // 1: wide shot
[], // 2: Rio dot, no links
["South America"], // 3
["South America", "Europe"], // 4
["South America", "Europe", "Africa"], // 5
["South America", "Europe", "Africa", "North America"], // 6
["South America", "Europe", "Africa", "North America", "Asia"], // 7
["South America", "Europe", "Africa", "North America", "Asia"], // 8: Asia continued
null, // 9: all
null // 10: stats
]
activeConts = continentSets[Math.min(crTriggerIndex, continentSets.length - 1)]
// Tooltip — created once, removed only when cell is invalidated
tipDiv = {
const tip = d3.select("body").append("div")
.style("position", "fixed")
.style("pointer-events", "none")
.style("background", "rgba(10,26,26,0.92)")
.style("color", "#f5e9c0")
.style("padding", "3px 8px")
.style("border-radius", "4px")
.style("font-size", "0.9rem")
.style("font-family", "'Bricolage Grotesque 96pt', sans-serif")
.style("font-weight", "bold")
.style("white-space", "nowrap")
.style("opacity", "0")
.style("transition", "opacity 0.1s")
.style("z-index", "99999")
invalidation.then(() => tip.remove())
return tip
}
// Stable SVG skeleton — created once, never rebuilt on scroll
mapSvg = {
const svg = d3.create("svg")
.attr("width", mapW)
.attr("height", mapH)
.style("display", "block")
.style("position", "absolute")
.style("top", "0")
.style("left", "0")
.style("background", "transparent")
svg.append("g").attr("class", "lines")
svg.append("g").attr("class", "dots")
svg.append("g").attr("class", "hits")
svg.append("g").attr("class", "rio")
return svg.node()
}
// Side-effect: update SVG — per-continent <g> groups so overlapping lines don't compound opacity
_updateMapSvg = {
const svg = d3.select(mapSvg)
if (crTriggerIndex < 2) {
svg.select(".lines").selectAll("*").remove()
svg.select(".dots").selectAll("*").remove()
svg.select(".hits").selectAll("*").remove()
svg.select(".rio").selectAll("*").remove()
return
}
const isFull = activeConts === null
const conts = isFull
? [...new Set(allFeatures.map(f => f.properties.continent))]
: activeConts
const currentCont = isFull ? null : conts[conts.length - 1]
const gKey = c => "cg-" + c.replace(/[\s/]/g, "_")
// Remove groups for continents no longer active (backward scrolling)
const contSet = new Set(conts)
const removeStale = sel =>
svg.select(sel).selectAll("g")
.filter(function() { return !contSet.has(d3.select(this).attr("data-cont")) })
.remove()
removeStale(".lines")
removeStale(".dots")
// Visible endpoints with continent field for per-group assignment
const visibleEps = (isFull ? eps : eps.filter(d => conts.includes(d.continent)))
.map(d => { const p = proj([d.ep_lon, d.ep_lat]); return p ? { name: d.name, cont: d.continent, px: p[0], py: p[1] } : null })
.filter(Boolean)
// Per-continent groups — group-level opacity prevents stacking of overlapping lines/dots
conts.forEach(cont => {
const key = gKey(cont)
const isCurrent = isFull ? true : cont === currentCont
const lineOp = isFull ? 0.7 : (isCurrent ? 1 : 0.25)
const dotOp = isFull ? 0.65 : (isCurrent ? 0.9 : 0.4)
// Lines group
let linesG = svg.select(".lines").select("." + key)
if (linesG.empty()) {
linesG = svg.select(".lines").append("g").attr("class", key).attr("data-cont", cont).attr("opacity", 0)
allFeatures.filter(f => f.properties.continent === cont).forEach((d, i) => {
const path = linesG.append("path")
.attr("d", geoPath(d))
.attr("fill", "none").attr("stroke", "#FFC04C")
.attr("stroke-width", 0.8).attr("stroke-opacity", 0.6)
const len = path.node().getTotalLength()
path.attr("stroke-dasharray", len + " " + len).attr("stroke-dashoffset", len)
.transition("draw").delay(i * 18).duration(500).ease(d3.easeLinear)
.attr("stroke-dashoffset", 0)
})
}
linesG.transition("dim").duration(400).attr("opacity", lineOp)
// Dots group
let dotsG = svg.select(".dots").select("." + key)
if (dotsG.empty()) {
dotsG = svg.select(".dots").append("g").attr("class", key).attr("data-cont", cont).attr("opacity", 0)
visibleEps.filter(e => e.cont === cont).forEach((d, i) => {
dotsG.append("circle")
.attr("cx", d.px).attr("cy", d.py).attr("r", 0)
.attr("fill", "#FFC04C").style("pointer-events", "none")
.transition("draw").delay(i * 18 + 250).duration(200).attr("r", 2)
})
}
dotsG.transition("dim").duration(400).attr("opacity", dotOp)
})
// Hit areas: flat, event handlers re-bound each update to keep visibleEps closure fresh
svg.select(".hits").selectAll("circle")
.data(visibleEps, d => d.name)
.join(
enter => enter.append("circle")
.attr("cx", d => d.px).attr("cy", d => d.py)
.attr("r", 5).attr("fill", "transparent").style("cursor", "pointer"),
update => update,
exit => exit.remove()
)
.on("mouseover", (event, d) => {
const nearby = visibleEps.filter(e => Math.hypot(e.px - d.px, e.py - d.py) <= 5)
tipDiv.html(nearby.map(e => e.name).join("<br>"))
.style("opacity", "1")
.style("left", (event.clientX + 12) + "px")
.style("top", (event.clientY - 10) + "px")
})
.on("mousemove", (event) => {
tipDiv.style("left", (event.clientX + 12) + "px")
.style("top", (event.clientY - 10) + "px")
})
.on("mouseout", () => tipDiv.style("opacity", "0"))
if (svg.select(".rio circle.rio-core").empty()) {
const [rx, ry] = proj([rio_lon, rio_lat])
const pulse = svg.select(".rio").append("circle")
.attr("cx", rx).attr("cy", ry)
.attr("r", 8).attr("fill", "none")
.attr("stroke", "#FFC04C").attr("stroke-width", 1.5)
.style("pointer-events", "none")
pulse.append("animate")
.attr("attributeName", "r").attr("values", "8;24")
.attr("dur", "2s").attr("repeatCount", "indefinite")
pulse.append("animate")
.attr("attributeName", "stroke-opacity").attr("values", "0.8;0")
.attr("dur", "2s").attr("repeatCount", "indefinite")
svg.select(".rio").append("circle")
.attr("class", "rio-core")
.attr("cx", rx).attr("cy", ry)
.attr("r", 5).attr("fill", "#FFC04C")
.style("pointer-events", "none")
}
}
// Side-effect: fade canvas — CSS transition for trigger boundaries, none during scroll
_canvasFade = {
if (crTriggerIndex <= 0 || crTriggerIndex > 2) {
cityCanvas.style.transition = "opacity 0.6s ease"
cityCanvas.style.opacity = 0
return
}
if (crTriggerIndex === 1) {
cityCanvas.style.transition = "opacity 0.6s ease"
cityCanvas.style.opacity = 1
return
}
// trigger 2 (progress block): fade out cities as the block activates
cityCanvas.style.transition = "opacity 1.2s ease 0.4s"
cityCanvas.style.opacity = 0
}
// Side-effect: grey out land on title slide
_mapTone = {
landCanvas.style.filter = crTriggerIndex === 0
? "saturate(0) brightness(0.6)"
: ""
}
_startCityDraw = {
if (crTriggerIndex === 0) cityCanvas._clear()
else cityCanvas._startDraw()
}
chart = {
const wrap = htl.html`<div style="position:relative;width:${mapW}px;height:${mapH}px;background:#0a1a1a;">`
wrap.appendChild(landCanvas)
wrap.appendChild(cityCanvas)
wrap.appendChild(mapSvg)
return wrap
}Rio de Janeiro: City of Friendships
Rio de JaneiroCity of Friendships↓
A sister city agreement, also called a twin city or twinning, is a formal bond between two cities: a commitment to cultural exchange, cooperation and goodwill across borders. According to Wikidata, 5470 cities worldwide have signed at least one. Each dot here is one of them.
Read more about the data
This is Rio de Janeiro.
A city of nearly seven million on Brazil’s Atlantic coast, once the capital of both Brazil and the Portuguese empire. Of all those cities on the map, Rio has more sister city agreements than any other.
The nearest connection is Niterói, just 11 km across Guanabara Bay. For over a century it served as the capital of Rio de Janeiro state, governing the region from the opposite shore.
From there the lines spread south and west: Asunción, the landlocked capital of Paraguay; Buenos Aires, across the mouth of the Rio de la Plata; and Cusco, the former capital of the Inca Empire, high in the Peruvian Andes.
Europe holds 26 connections. In 1807, Napoleon invaded Portugal and the royal court fled to Rio, making Rio the capital of the Portuguese empire for 13 years. That history is still visible on the map.
Of those 26 European connections, 14 are with Portuguese cities: Lisbon, Porto, Braga, Coimbra, Guimarães and Fátima, the pilgrimage town.
That colonial thread extends to Africa. Praia, Luanda and Bissau are all capitals of Portuguese-speaking nations born of the same colonial moment. In 1648, a fleet sent from Rio retook Luanda from the Dutch, and Angola was briefly administered from Rio during the Portuguese court’s years in Brazil.
Lagos tells the reverse: after abolition, freed enslaved Brazilians of Yoruba origin returned to Nigeria and settled in the Aguda quarter. Fifteen African connections in all, from the Atlantic coast to Johannesburg and Maputo on the Indian Ocean.
Twelve North American connections, spread from the Caribbean to Canada. Havana and Santo Domingo sit closest to Brazil; the Central American capitals San José and Managua; and Mexico City. On the US coast, Miami and Miami Beach: Florida is home to more than 300 000 Brazilians.
Atlanta has been a partner since 1972: Mayor Sam Massell signed the agreement after a visit to Brazil, encouraged by then-Governor Jimmy Carter, who had become enchanted with the city.
Six of the Asian connections are with cities in Israel and Palestine: Tel Aviv, Jerusalem, Ra’anana, Hebron and others. Brazil has one of the largest Jewish communities outside Israel.
Further into Asia: Yerevan in Armenia, Dubai, Samarkand in Uzbekistan, 13 000 km away.
And Beijing, a partner since 1986. On the eve of the 2016 Rio Games, Beijing lit the Rio Olympic emblem at the National Stadium to mark 30 years of sisterhood. Both cities have hosted the Summer Olympics.
South Korea adds Seoul, Busan and Incheon. The arc ends in Japan: Kobe, at nearly 19 000 km, is the most distant connection Rio has ever made.
93 sister cities across 5 continents.
Nearest:
Niterói, 11 km across the bay.
Most distant:
Kobe, 18 717 km away.
Northernmost:
Saint Petersburg, at 60°N.
Southernmost:
Puerto Varas, at 41.3°S.
All lines combined: 776 848 km, or 19.4 times around Earth.
Data from Wikidata, collected by Ahmad Barclay for the Twin cities explorer. Wikidata may contain errors and omissions. Made by Georgios Karamanis with Closeread.