Back to list

World Map

Create a world map, with sized & colored bubbles for each country. Check this out on desktop and mobile 😉.

Map.svelte
See on Github
<script>
  import { extent, max } from "d3-array"
  import { interpolate, interpolateHcl } from "d3-interpolate"
  import { scaleSqrt, scaleLinear } from "d3-scale"
  import {
    geoOrthographic,
    geoPath,
    geoGraticule10,
    geoEqualEarth,
  } from "d3-geo"

  import countryShapes from "./country-shapes.js"
  import scaleCanvas from "./scale-canvas.js"
  import { tweened } from "./tweened-staggered.js"

  // utility function for translating elements
  const move = (x, y) => `transform: translate(${x}px, ${y}px`

  export let data = []
  // accessor functions to quickly pivot between data structures
  export let nameAccessor = d => d[0]
  export let rAccessor = d => d[1]
  export let colorAccessor = d => d[2]

  let width = 1200
  $: isVertical = width < 450
  $: height = width * (isVertical ? 2 : 0.7)

  const sphere = { type: "Sphere" }
  let canvasElement
  let blankMap

  // create projections - vertical orientations have two globes
  $: projections = isVertical
    ? [
        geoOrthographic()
          .fitSize([width, width], sphere)
          .rotate([-75, -20]),
        geoOrthographic()
          .fitSize([width, width], sphere)
          .translate([width / 2, height * 0.7])
          .rotate([70, -20]),
      ]
    : [
        geoEqualEarth()
          .fitSize([width, height], sphere)
          .rotate([-6, 0]),
      ]

  // make me some scales!
  $: rScale = scaleSqrt()
    .domain([0, max(data, rAccessor)])
    .range([0, width * height * 0.000036])
    .clamp(true)

  $: colorScale = scaleLinear()
    .domain(extent(data, colorAccessor))
    .range(["#274B55", "#f8f8f8"])
    .interpolate(interpolateHcl)
    .clamp(true)

  // draw a blank world map
  const drawCanvas = () => {
    if (!canvasElement) return
    const ctx = canvasElement.getContext("2d")
    scaleCanvas(canvasElement, ctx, width, height)

    const drawMap = projection => {
      const path = geoPath(projection, ctx)
      const drawPath = shape => {
        ctx.beginPath()
        path(shape)
      }
      drawPath(sphere)

      const fill = color => {
        ctx.fillStyle = color
        ctx.fill()
      }
      const stroke = color => {
        ctx.strokeStyle = color
        ctx.stroke()
      }
      drawPath(sphere)
      fill("#fff")
      stroke("#bbb")
      drawPath(geoGraticule10())
      stroke("#eee")

      countryShapes.forEach(shape => {
        drawPath(shape)
        fill("#f8f8f8")
        stroke("#ccc")
      })

      drawPath(sphere)
      stroke("#ccc")
    }
    projections.forEach(drawMap)

    blankMap = ctx.getImageData(0, 0, width * 2, height * 2)
  }
  $: width, projections, drawCanvas()

  // tween bubble sizes & colors
  let bubbles = tweened(countryShapes.map(d => [0, "#fff"]), {
    duration: 1000,
  })
  $: {
    const newBubbles = data.map((d, i) => [
      rScale(rAccessor(d)),
      colorScale(colorAccessor(d)),
    ])
    bubbles.set(newBubbles)
  }

  // draw one bubble for each country
  const drawBubbles = () => {
    if (!canvasElement) return
    if (!blankMap) return
    const ctx = canvasElement.getContext("2d")

    ctx.putImageData(blankMap, 0, 0)

    ctx.globalCompositeOperation = "multiply"
    const paths = projections.map(geoPath)
    data.forEach((d, i) => {
      const name = nameAccessor(d)
      const countryShape = countryShapes.find(
        country => country["properties"]["geounit"] == nameAccessor(d),
      )
      if (!countryShape) return
      let centroid = paths[0].centroid(countryShape)
      if (!centroid) return
      if (
        paths[1] &&
        (Number.isNaN(centroid[0]) ||
          centroid[0] < width * 0.06 ||
          ["United States of America", "Canada"].includes(name))
      )
        centroid = paths[1].centroid(countryShape)
      const [r, color] = $bubbles[i]
      ctx.beginPath()
      ctx.fillStyle = color
      ctx.arc(...centroid, r, 0, 2 * Math.PI)
      ctx.fill()
    })

    ctx.globalCompositeOperation = "normal"
  }

  $: data, projections, $bubbles, drawBubbles()
</script>

<figure class="canvas-wrapper" bind:clientWidth="{width}">
  <canvas
    style="{`width: ${width}px; height: ${height}px`}"
    bind:this="{canvasElement}"></canvas>
</figure>

<style>
  .canvas-wrapper {
    position: relative;
    width: 100%;
    margin: 0;
  }
</style>

Usage example:

MapWrapper.svelte
See on Github
<script>
  import countryShapes from "./country-shapes.js"

  import Map from "./Map.svelte"

  const createData = () => {
    return countryShapes.map(d => [
      d.properties.geounit,
      Math.random() < 0.13 ? Math.random() : 0,
      Math.random(),
    ])
  }
  let data = createData()
</script>

<div class="wrapper">
  <Map
    {data}
    nameAccessor="{d => d[0]}"
    rAccessor="{d => d[1]}"
    colorAccessor="{d => d[2]}" />
</div>
<div class="note">Click to update bubble sizes</div>
<svelte:window
  on:click="{() => (data = createData())}"
  on:touchend="{() => (data = createData())}" />

<style>
  .wrapper {
    background: #f8f8f8;
    margin: -1em;
    padding: 1em;
  }
  .note {
    position: absolute;
    top: 0;
    font-style: italic;
    color: var(--text-light);
  }
</style>
Click to update bubble sizes