block by gabrielflorit cacdbf409710d7df2457d3a4d007d0db

Line interpolation with Canvas

Full Screen

This is an experiment to see what’s the most performant way of animating hundreds of lines over time.

This one uses canvas to draw the lines. Every tick, the pre-projected lines are sliced by a given pct, and then drawn. This approach is quite slow, and the bottleneck is the thousands of context.lineTo / context.moveTo canvas calls, not the line interpolation.

Use the slider to manually adjust the line lengths.

Also see Line interpolation with SVG

Made with blockup

script.js

import setupCanvas from './setupCanvas.js'
import getFeatures from './getFeatures.js'
import draw from './draw.js'
import interpolateLineStrings from './interpolateLineStrings.js'

// Use body's offset width as the canvas overall width.
const width = document.body.offsetWidth

d3.json('./all.json', topology => {

  // Get geo features.
  const { all, ocean, lines } = getFeatures(topology)

  // Create a null path.
  const path = d3.geoPath()

  // Use the null path to get overall topology aspect.
  const b = path.bounds(all)
  const aspect = (b[1][0] - b[0][0])/(b[1][1] - b[0][1])

  // Set height as a multiple of width.
  const height = width / aspect

  // Use geoIdentity so we can use fitSize.
  const projection = d3.geoIdentity().fitSize([width, height], all)

  // Set the projection to geoIdentity.
  path.projection(projection)

  // Setup canvas.
  const { ctx, canvas, dpRatio } = setupCanvas({
    container: document.body,
    width,
    height,
  })

  // Set canvas context on path.
  path.context(ctx)

  // Clear the canvas.
  draw.reset({ dpRatio, canvas, ctx })

  // Draw ocean.
  draw.ocean({ ctx, ocean, path })

  const drawLines = pct => {
    // Clear the canvas.
    draw.reset({ dpRatio, canvas, ctx })

    // Draw the ocean.
    draw.ocean({ ctx, ocean, path })

    // Slice all the lines by a given pct.
    const slices = interpolateLineStrings({
      lines,
      pct,
    })

    // Draw the lines.
    draw.lines({
      ctx,
      path,
      lines: slices,
    })
  }

  // Set the duration of a complete loop.
  const duration = 4000
  let resetTime = 0

  const timer = d3.timer((elapsed) => {

    let delta = elapsed - resetTime
    if (delta > duration) {
      resetTime = elapsed
      delta = elapsed - resetTime
    }

    const pct = delta / duration
    const adjustedPct = pct < 0.5 ? 2 * pct : 2 * (1 - pct)

    drawLines(adjustedPct)

  })

  d3.select('input').on('input', function() {

    timer.stop()
    const pct = this.value

    drawLines(pct)

  })
})

index.html

<!DOCTYPE html>
<title>blockup</title>
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<link href='dist.css' rel='stylesheet' />
<body>
	<canvas></canvas>
	<script src='d3.v4.min.js'></script>
	<script src='topojson.min.js'></script>
	<script src='dist.js'></script>
	<input type='range' min=0 max=1 step=0.01 value=0.5>
</body>

Makefile

W := 960
H := 500

bin := ./node_modules/.bin

all: clean all.json

clean:
	rm -rf output

output/ne_110m_ocean.zip:
	mkdir -p $(dir $@)
	curl -Lo $@ http://naciscdn.org/naturalearth/110m/physical/$(notdir $@)

output/ne_110m_ocean.shp: output/ne_110m_ocean.zip
	unzip -od $(dir $@) $<
	touch $@

output/ocean.json: output/ne_110m_ocean.shp
	${bin}/shp2json -n $< \
		| ${bin}/geostitch -n \
		> $@
	ls -lh $@

output/lines.json:
	cp $(notdir $@) $@

all.json: output/ocean.json output/lines.json
	bash -c "${bin}/geo2topo -n \
		all=<(cat $^ | ${bin}/geoproject 'd3.geoMollweide()' -n) \
		| ${bin}/toposimplify -p 100 -f \
		| ${bin}/topoquantize 100 \
		> $@"
	ls -lh $@

dist.css

*{box-sizing:border-box}body{margin:0;padding:0}span{position:absolute;top:0;left:0}input{width:100%}canvas{display:block}

draw.js

const ocean = ({ ocean, ctx, path }) => {
  ctx.fillStyle = 'rgba(0,111,145,0.15)'
  ctx.beginPath()
  path({
    type: 'FeatureCollection',
    features: ocean,
  })
  ctx.fill()
}

const lines = ({ lines, ctx, path }) => {
  ctx.strokeStyle = '#00485e'
  ctx.beginPath()
  path({
    type: 'FeatureCollection',
    features: lines,
  })
  ctx.stroke()
}

const reset = ({ dpRatio, canvas, ctx }) => {
  ctx.setTransform(dpRatio, 0, 0, dpRatio, 0, 0)
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.lineJoin = 'round'
  ctx.lineCap = 'round'
}

const draw = {
  ocean,
  lines,
  reset,
}

export default draw

getFeatures.js

import { lineDistance } from './util.js'

const getFeatures = topology => {

  const all = topojson.feature(topology, topology.objects.all)
  const ocean = all.features.filter(d => d.properties.featurecla)

  const lines = all.features
    // Choose features that are not `featurecla` (ocean),
    .filter(d => !d.properties.featurecla)
    // limit to `LineString` (we don't need to focus on `MultiLineString` for now),
    .filter(d => d.geometry.type === 'LineString')
    // and calculate overall distance.
    .map(d => ({
      ...d,
      properties: {
        ...d.properties,
        distance: lineDistance(d.geometry.coordinates),
      },
    }))

  return {
    all,
    ocean,
    // Return 10 times the amount of lines (so we can debug performance).
    lines: lines
      .concat(lines)
      .concat(lines)
      .concat(lines)
      .concat(lines)
      .concat(lines)
      .concat(lines)
      .concat(lines)
      .concat(lines)
      .concat(lines),
  }

}

export default getFeatures

interpolateLineStrings.js

import { lineSliceAlong } from './util.js'

const interpolateLineStrings = ({ lines, pct }) =>
  lines
    .map(line => ({
      ...line,
      geometry: {
        ...line.geometry,
        coordinates: lineSliceAlong({
          stop: pct * line.properties.distance,
          line: line.geometry.coordinates,
        }),
      },
    }))

export default interpolateLineStrings

package.json

{
  "dependencies": {
    "d3-geo-projection": "^2.1.2",
    "shapefile": "^0.6.2",
    "topojson": "^3.0.0",
    "topojson-client": "^3.0.0",
    "topojson-server": "^3.0.0",
    "topojson-simplify": "^3.0.1"
  }
}

setupCanvas.js

const setupCanvas = ({ container, width, height }) => {

  const dpRatio = window.devicePixelRatio || 1
  const canvas = container.querySelector('canvas')
  canvas.width = width * dpRatio
  canvas.height = height * dpRatio
  canvas.style.width = `${width}px`
  canvas.style.height = `${height}px`

  const ctx = canvas.getContext('2d')
  ctx.scale(dpRatio, dpRatio)

  return { canvas, ctx, dpRatio }

}

export default setupCanvas

style.styl

*
	box-sizing border-box

body
	margin 0
	padding 0

input
	width 100%

canvas
	display block

util.js

// The following is significantly based on
// https://github.com/mapbox/cheap-ruler/blob/master/index.js.

const distance = (a, b) => {
  const dx = (a[0] - b[0])
  const dy = (a[1] - b[1])
  return Math.sqrt(dx * dx + dy * dy)
}

const lineSliceAlongMultiple = (stop, lines) => {

}

const lineSliceAlong = ({ line, stop }) => {
  let sum = 0
  const slice = []

  for (let i = 0; i < line.length - 1; i++) {
    let p0 = line[i]
    let p1 = line[i + 1]
    const d = distance(p0, p1)

    sum += d

    if (sum >= stop) {
      slice.push(interpolate(p0, p1, (stop - (sum - d)) / d))
      return slice
    } else {
      slice.push(p1)
    }
  }

  return slice
}

const interpolate = (a, b, t) => {
  const dx = b[0] - a[0]
  const dy = b[1] - a[1]
  return [
    a[0] + dx * t,
    a[1] + dy * t,
  ]
}

const lineDistance = points => {
  let total = 0
  for (let i = 0; i < points.length - 1; i++) {
    total += distance(points[i], points[i + 1])
  }
  return total
}

export {
  lineSliceAlong,
  lineDistance,
}

yarn.lock

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


array-source@0.0:
  version "0.0.3"
  resolved "https://registry.yarnpkg.com/array-source/-/array-source-0.0.3.tgz#6ee635763c4fb4cd9990f876321cb29e6d7dded1"

commander@2:
  version "2.11.0"
  resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"

d3-array@1:
  version "1.2.0"
  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.0.tgz#147d269720e174c4057a7f42be8b0f3f2ba53108"

d3-geo-projection@^2.1.2:
  version "2.1.2"
  resolved "https://registry.yarnpkg.com/d3-geo-projection/-/d3-geo-projection-2.1.2.tgz#7df8e1e9d046d631c6509f7e531357d4adc24aa3"
  dependencies:
    commander "2"
    d3-array "1"
    d3-geo "^1.1.0"

d3-geo@^1.1.0:
  version "1.6.4"
  resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.6.4.tgz#f20e1e461cb1845f5a8be55ab6f876542a7e3199"
  dependencies:
    d3-array "1"

file-source@0.6:
  version "0.6.1"
  resolved "https://registry.yarnpkg.com/file-source/-/file-source-0.6.1.tgz#ae189d4993766b865a77f83adcf9b9a504cd37dc"
  dependencies:
    stream-source "0.3"

path-source@0.1:
  version "0.1.2"
  resolved "https://registry.yarnpkg.com/path-source/-/path-source-0.1.2.tgz#c5a26c44fb92cd32b930e8e49b1fe5750f2ca70b"
  dependencies:
    array-source "0.0"
    file-source "0.6"

shapefile@^0.6.2:
  version "0.6.2"
  resolved "https://registry.yarnpkg.com/shapefile/-/shapefile-0.6.2.tgz#3e232674290234a6474b6ccaca8899a796fe98ed"
  dependencies:
    array-source "0.0"
    commander "2"
    path-source "0.1"
    slice-source "0.4"
    stream-source "0.3"
    text-encoding "0.6.1"

slice-source@0.4:
  version "0.4.1"
  resolved "https://registry.yarnpkg.com/slice-source/-/slice-source-0.4.1.tgz#40a57ac03c6668b5da200e05378e000bf2a61d79"

stream-source@0.3:
  version "0.3.4"
  resolved "https://registry.yarnpkg.com/stream-source/-/stream-source-0.3.4.tgz#0427c1fb128b0bd2d884d868dcc894208f9bda3b"

text-encoding@0.6.1:
  version "0.6.1"
  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.1.tgz#4de1130e61d50dd867040428aa15656efddcb1c8"

topojson-client@3, topojson-client@3.0.0, topojson-client@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.0.0.tgz#1f99293a77ef42a448d032a81aa982b73f360d2f"
  dependencies:
    commander "2"

topojson-server@3.0.0, topojson-server@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/topojson-server/-/topojson-server-3.0.0.tgz#378e78e87c3972a7b5be2c5d604369b6bae69c5e"
  dependencies:
    commander "2"

topojson-simplify@3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/topojson-simplify/-/topojson-simplify-3.0.0.tgz#58527d26ca85bd8589b5800f6c10e93885245ae5"
  dependencies:
    commander "2"
    topojson-client "3"

topojson-simplify@^3.0.1:
  version "3.0.1"
  resolved "https://registry.yarnpkg.com/topojson-simplify/-/topojson-simplify-3.0.1.tgz#bd631938f405d283c8d846e5c1fad4085699ad51"
  dependencies:
    commander "2"
    topojson-client "3"

topojson@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/topojson/-/topojson-3.0.0.tgz#c2aca1b76435e801dce3f229586bbd5767115ce3"
  dependencies:
    topojson-client "3.0.0"
    topojson-server "3.0.0"
    topojson-simplify "3.0.0"