Scratchpad/

Ringpost

A concentric projection of the 1,000 nearest capitals and other most populous cities.

Loading...
// 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 = ~~(p.name.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.world ? "●" : 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
          }

          if (p.world && !p.cap && !p.mega) {
            console.log("Strange data", p.name)
          }

          // 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, 0)
      ctx.fillText("Capital city", fontSize * 2.4, 0)

      textWidth = ctx.measureText("●").width
      ctx.fillText("●", 0.5 * fontSize - textWidth / 2, rowHeight)
      ctx.fillText("World city", fontSize * 2.4, rowHeight)

      textWidth = ctx.measureText("◆").width
      ctx.fillText("◆", 0.5 * fontSize - textWidth / 2, rowHeight * 2)
      ctx.fillText("Mega city", fontSize * 2.4, rowHeight * 2)

      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()
    },
  ])
}