A concentric projection of the 1,000 nearest capitals and other most populous cities.
Get occasional updates about new fonts, designs and other interesting things.
// Concentricities
// Distance and population of top 1000 populated places
// Populations under 1 million may not be shown
// Source: Natural Earth
import places from "./data.asset.mjs"
import sequence from "/scratchpad/_lib/sequence.asset.mjs"
import palette from "/scratchpad/_lib/palette.asset.mjs"
const C = 40007.863 // Earth's circumference (KM)
const M = 1000000
const K = 1000
const π = Math.PI
const rad = (n) => n * (π / 180)
const wrapText = false
// Overwritten based on canvas size
let fontSize = 24
const fontString = (data = {}) => {
data = {
weight: 400,
size: fontSize,
family: "'sans', sans-serif",
data,
}
return `${data.weight} ${data.size}px ${data.family}`.trim()
}
const format = {
pop: (p) => (p > M ? `${+(p / M).toFixed(1)}M` : `${(p / K).toFixed(0)}K`),
dist: (d) => (d ? `${+((d * C) / 2 / 1).toFixed(0)}km` : ""),
truncate: (t) => {
const d = ~~(t.length * 0.4)
return [t.slice(0, d).trim(), "…", t.slice(-d).trim()].join("")
},
}
const geoBearing = (p1, p2) => {
const dl = p2[0] - p1[0]
const dy = Math.sin(dl) * Math.cos(p2[1])
const dx =
Math.cos(p1[1]) * Math.sin(p2[1]) -
Math.sin(p1[1]) * Math.cos(p2[1]) * Math.cos(dl)
return -π / 2 + Math.atan2(dy, dx)
}
const geoDistance = (p1, p2) => {
const dLat = (p2[1] - p1[1]) / 2
const dLon = (p2[0] - p1[0]) / 2
const a =
Math.sin(dLat) * Math.sin(dLat) +
Math.sin(dLon) * Math.sin(dLon) * Math.cos(p1[1]) * Math.cos(p2[1])
return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const where = (place) => [place.lng, place.lat]
export default async (canvas) => {
const value = document.querySelector("[name='location']").value
const place = places.find((d) => d.name === value)
let center
if (value.match(",")) {
center = value.split(/,\s+/).map(parseFloat)
} else {
center = where(place)
}
const ctx = canvas.getContext("2d")
const { width, height } = canvas
fontSize = Math.round(width * 0.0048)
canvas.style.fontFeatureSettings = "'case' 1"
ctx.font = fontString({ weight: 700 })
const rings = 51
const radius = width / 2
const cx = width / 2
const cy = height / 2
const gap = width * 0.0008
const radii = Array.from(
{ length: rings },
(n, i) => radius * 0.1 + (radius * 0.9 * i) / rings
)
// Create a dictionary of places with distance and bearing from location
const map = places
.filter(
(a) => a === places.find((b) => a.name === b.name && a.code === b.code)
)
.map((data) => {
const p1 = center.map(rad)
const p2 = where(data).map(rad)
const d = geoDistance(p1, p2) / π
const t = geoBearing(p1, p2)
return { t, d, data }
})
// The number of items to include in each ring
const limit = (i) => (i === 0 ? 1 : Math.min(5 + Math.floor(i / 1.65), 35))
// The total number of items needed based on the per-ring count
const total = radii.map((n, i) => limit(i)).reduce((m, n) => m + n, 0)
const groups = map
// First take a slice of the most populous places
.sort((a, b) =>
b.cap === a.cap ? (a.pop > b.pop ? -1 : 1) : b.cap > a.cap ? 1 : -1
)
.slice(0, total)
// Then sort them by distance
.sort((a, b) => a.d - b.d)
.reduce(
(m, e) => {
if (m.slice(-1)[0].length >= limit(m.length - 1)) m.push([])
m.slice(-1)[0].push(e)
return m
},
[[]]
)
.map((g) => g.sort((a, b) => (a.t < b.t ? 1 : -1)))
// Add copyright to second-last ring
groups
.slice(-2)[0]
.splice(20, 0, { name: "© s-ings.com", pop: -2 * M, copyright: 1 })
return sequence([
() => {
ctx.clearRect(0, 0, width, height)
ctx.save()
ctx.translate(0, height * -0.125)
ctx.beginPath()
ctx.strokeStyle = palette.dark
ctx.lineWidth = radii[1] - radii[0] - gap * 2
groups.forEach((group, j) => {
const r = radii[j]
const hasOneLabel = group.length === 1
const arcGap = hasOneLabel ? 0 : gap / r
const size = (p) =>
fontSize * (p.name.length + 13) +
Math.max(p.pop, M / 5) / (radius * 20)
const sum = group.reduce((m, p) => m + size(p), 0)
let angle = -π / 2 + π * 2 * ((j - 1) / groups.length)
group.forEach((p) => {
const arcSize = π * 2 * (size(p) / sum)
let t0 = angle + arcGap
let t1 = angle + arcSize - arcGap
let tm = t0 + arcSize / 2
let ok = true
// Flip the text order for 80% of the bottom circle
const flip = !hasOneLabel && tm > π * 0.1 && tm < π * 0.9
ctx.beginPath()
ctx.arc(cx, cy, r, t0, t1)
ctx.stroke()
let pop = p.copyright ? "" : format.pop(p.pop)
let dist = p.copyright ? "" : format.dist(p.d)
let text = [
p.code,
p.cap ? "★" : null,
p.mega ? "◆" : null,
p.name,
pop,
dist,
].filter((s) => s)
t0 += arcGap * 2
const measure = (text) => {
const id = pop ? -2 : -1
ctx.font = fontString({ weight: 700 })
const w0 = ctx.measureText(text.slice(0, id).join(" ") + " ").width
ctx.font = fontString({ weight: 300 })
const w1 = ctx.measureText(text.slice(id).join(" ")).width
return w0 + w1
}
// Not enough space, removing population
if ((arcSize - arcGap * 4.5) * r < measure(text)) {
text.splice(text.indexOf(pop), 1)
pop = ""
}
// Still not enough space, truncating name
if ((arcSize - arcGap * 4.5) * r < measure(text)) {
text[text.indexOf(p.name)] = format.truncate(p.name)
}
// If only one item, center text at the top of the circle
if (hasOneLabel) {
t0 = -π / 2 - measure(text) / 2 / r
}
// Don't draw the text, but provide a function to do so later
// as `fillText` will be reasonably slow for this many labels
p.drawText = () => {
if (flip) text.reverse()
text = text.join(" ").split("")
if (flip) text.reverse()
text.forEach((char, i, text) => {
const isBold =
i < text.length - [pop, dist].join(" ").trim().length
ctx.font = fontString({
weight: isBold ? 700 : 300,
})
const w = ctx.measureText(char).width
const θ = t0
const tx = cx + Math.cos(θ) * r
const ty = cy + Math.sin(θ) * r
if (ok) {
if (θ + w / r > t1 - (2 * gap + w) / r) {
if (i < text.length - 1) {
console.log("Trimming label for", char, p.name, p.country)
char = "…"
}
ok = false
}
ctx.save()
ctx.translate(tx, ty)
ctx.rotate(θ + π / 2)
if (flip) {
ctx.rotate(π)
ctx.translate(-w, 0)
}
ctx.fillText(char, 0, 0.36 * fontSize)
ctx.restore()
}
t0 += ((char === " " ? 0.8 : 1) * w) / r
})
}
angle = (angle + arcSize) % (π * 2)
})
})
},
// Draw arc labels
() => {
ctx.fillStyle = palette.canvas
ctx.beginPath()
groups.forEach((group) => {
group.forEach((point) => point.drawText())
})
ctx.restore()
},
// Draw legend
() => {
const flat = groups
.reduce((m, e) => [m, e], [])
.filter((p) => p.country)
.sort((a, b) => (a.d > b.d ? 1 : -1))
const countries = flat
.map(({ country, code }) => ({
country,
code,
count: flat.filter((c) => c.code === code).length,
}))
.filter((e, i, a) => a.indexOf(a.find((o) => e.code === o.code)) === i)
.sort((a, b) => a.code.localeCompare(b.code))
let textWidth
let dy = 0
let columnCount = 8
let columnWidth = 19 * fontSize
let rowHeight = 1.6 * fontSize
let rowCount = Math.ceil(countries.length / columnCount)
ctx.save()
ctx.translate(
width / 2 - columnWidth * (columnCount / 2),
height - (rowCount + 0.5) * rowHeight
)
ctx.save()
ctx.translate(0, -rowHeight * 6)
ctx.fillStyle = palette.dark
ctx.font = fontString({ weight: 700 })
ctx.fillText("Concentricities", 0, 0)
ctx.font = fontString({ weight: 300 })
ctx.fillText(`Population and distance from ${place.name}`, 0, rowHeight)
ctx.fillText(`${total} Cities`, 0, rowHeight * 2)
ctx.translate(columnWidth * (columnCount - 1), 0)
textWidth = ctx.measureText("★").width
ctx.fillText("★", 0.5 * fontSize - textWidth / 2, rowHeight * 0)
ctx.fillText("Capital city", fontSize * 2.4, rowHeight * 0)
textWidth = ctx.measureText("◆").width
ctx.fillText("◆", 0.5 * fontSize - textWidth / 2, rowHeight * 1)
ctx.fillText("Mega city", fontSize * 2.4, rowHeight * 1)
ctx.restore()
countries.forEach((c, i) => {
const n = c.country
.replace(/\band the Grenadines\b/, "")
.replace(/\bof America/, "")
.replace(/\bSaint\b/, "St.")
.replace(/ \((Kinshasa|Brazzaville)\)/, "-$1")
.replace(/(.{12,})Republic\b/, "$1Rep.")
.replace(/\bFederated States of\b/, "")
const x = Math.floor((i + dy) / rowCount) * columnWidth
const y = (1 + ((i + dy) % rowCount)) * rowHeight
const dx = fontSize
const lines = wrapText ? n.match(/.{1,8}[\S]+/gi) : [n]
dy += lines.length - 1
ctx.font = fontString({ weight: 700 })
ctx.fillText(c.code, x, y)
ctx.font = fontString({ weight: 300 })
textWidth = ctx.measureText(c.count).width
ctx.fillText(c.count, x + dx * 2.75 - textWidth / 2, y)
lines.forEach((line, l) =>
ctx.fillText(line.trim(), x + dx * 4, y + l * rowHeight)
)
})
ctx.restore()
},
])
}