block by gabrielflorit 7b9194aaa5117d84906ff56e7f529471

Racing track lap counter

Full Screen

Made with blockup

script.js

import drawTrack from './drawTrack.js'
import drawCars from './drawCars.js'

const container = d3.select('.circuit')

// Draw the track.
const { g, getCoords } = drawTrack({ container, track: circuitTracks[0] })

// Create the state.
let state = {
  car: {
    position: 1
  },
  time: {
    start: 0,
    now: 0
  }
}

const formatTime = d3.timeFormat('%M:%S:%L')

const updateCounter = () => {
  const counterDiv = document.querySelector('.counter')
  counterDiv.querySelector('.lap .value').textContent = Math.floor(
    state.car.position
  )
  counterDiv.querySelector('.time .value').textContent = formatTime(
    state.time.now
  )
}

const draw = () => {
  // Draw the car.
  drawCars({ g, getCoords, cars: [state.car] })

  // Update the counter
  updateCounter()
}

draw()

const tick = elapsed => {
  const { car, time } = state

  // Move the car.
  const newPosition = car.position + 0.0003

  // Did we just start a new lap? (i.e. cross the finish line)
  const isNewLap =
    car.position === 1 || Math.floor(car.position) !== Math.floor(newPosition)

  // If we started a new lap, reset the time.
  const start = isNewLap ? elapsed : time.start

  // Update the state.
  state = {
    ...state,
    car: {
      ...car,
      position: newPosition
    },
    time: {
      ...time,
      start,
      now: elapsed - start
    }
  }
}

d3.timer(elapsed => {
  tick(elapsed)
  draw()
})

index.html

<!DOCTYPE html>
<title>blockup</title>
<link href='dist.css' rel='stylesheet' />
<body>
  <div class='circuit'>
    <div class='counter'>
      <p class='lap'><span class='label'>Lap:</span> <span class='value'></span></p>
      <p class='time'><span class='label'>Lap time:</span> <span class='value'></span></p>
    </div>
  </div>
	<script src='d3.v4.min.js'></script>
  <script src='circuitTracks.js'></script>
	<script src='dist.js'></script>
</body>

dist.css

*{box-sizing:border-box}body{background:rgba(0,0,0,.1);font-family:sans-serif;font-size:.85em}.circuit{max-width:500px;position:relative;margin:0 auto}.circuit svg{display:block;margin:0 auto}.circuit svg path.track{fill:rgba(0,0,0,.1);stroke:#000}.circuit svg .finish-line{stroke:#000;stroke-width:3px}.circuit .counter{position:absolute;bottom:0;left:0}

dist.js

!function(n){function t(a){if(e[a])return e[a].exports;var c=e[a]={i:a,l:!1,exports:{}};return n[a].call(c.exports,c,c.exports,t),c.l=!0,c.exports}var e={};t.m=n,t.c=e,t.i=function(n){return n},t.d=function(n,e,a){t.o(n,e)||Object.defineProperty(n,e,{configurable:!1,enumerable:!0,get:a})},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,"a",e),e},t.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)},t.p="",t(t.s=2)}([function(module,exports,__webpack_require__){"use strict";eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nvar drawCars = function drawCars(_ref) {\n  var g = _ref.g,\n      cars = _ref.cars,\n      getCoords = _ref.getCoords;\n\n  // join\n  var circles = g.selectAll('circle.car').data(cars);\n\n  // update\n  circles.attr('transform', function (d) {\n    return 'translate(' + getCoords(d.position) + ')';\n  });\n\n  // enter\n  circles.enter().append('circle').attr('class', 'car').attr('cx', 0).attr('cy', 0).attr('r', 4).attr('transform', function (d) {\n    return 'translate(' + getCoords(d.position) + ')';\n  });\n\n  circles.exit().remove();\n};\n\nexports.default = drawCars;//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMC5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy9kcmF3Q2Fycy5qcz9hMDAwIl0sInNvdXJjZXNDb250ZW50IjpbImNvbnN0IGRyYXdDYXJzID0gKHsgZywgY2FycywgZ2V0Q29vcmRzIH0pID0+IHtcbiAgLy8gam9pblxuICBjb25zdCBjaXJjbGVzID0gZy5zZWxlY3RBbGwoJ2NpcmNsZS5jYXInKS5kYXRhKGNhcnMpXG5cbiAgLy8gdXBkYXRlXG4gIGNpcmNsZXNcbiAgICAuYXR0cigndHJhbnNmb3JtJywgZCA9PiBgdHJhbnNsYXRlKCR7Z2V0Q29vcmRzKGQucG9zaXRpb24pfSlgKVxuXG4gIC8vIGVudGVyXG4gIGNpcmNsZXNcbiAgICAuZW50ZXIoKVxuICAgIC5hcHBlbmQoJ2NpcmNsZScpXG4gICAgLmF0dHIoJ2NsYXNzJywgJ2NhcicpXG4gICAgLmF0dHIoJ2N4JywgMClcbiAgICAuYXR0cignY3knLCAwKVxuICAgIC5hdHRyKCdyJywgNClcbiAgICAuYXR0cigndHJhbnNmb3JtJywgZCA9PiBgdHJhbnNsYXRlKCR7Z2V0Q29vcmRzKGQucG9zaXRpb24pfSlgKVxuXG4gIGNpcmNsZXMuZXhpdCgpLnJlbW92ZSgpXG59XG5cbmV4cG9ydCBkZWZhdWx0IGRyYXdDYXJzXG5cblxuXG4vLyBXRUJQQUNLIEZPT1RFUiAvL1xuLy8gZHJhd0NhcnMuanMiXSwibWFwcGluZ3MiOiI7Ozs7O0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFDQTtBQUFBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUFBO0FBQ0E7QUFDQTtBQUNBO0FBT0E7QUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EiLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///0\n")},function(module,exports,__webpack_require__){"use strict";eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nvar drawTrack = function drawTrack(_ref) {\n  var container = _ref.container,\n      track = _ref.track;\n\n  var margin = 10;\n  var dimension = container.node().offsetWidth - margin * 2;\n\n  var xExtent = d3.extent(track, function (d) {\n    return d.x;\n  });\n  var x = d3.scaleLinear().domain(xExtent);\n\n  var yExtent = d3.extent(track, function (d) {\n    return d.y;\n  });\n  var y = d3.scaleLinear().domain(yExtent);\n\n  var aspect = (xExtent[1] - xExtent[0]) / (yExtent[1] - yExtent[0]);\n\n  var width = Math.min(dimension * aspect, dimension);\n  var height = Math.min(dimension / aspect, dimension);\n\n  x.range([0, width]);\n  y.range([0, height]);\n\n  var svg = container.append('svg').attr('width', width + 2 * margin).attr('height', height + 2 * margin);\n\n  var g = svg.append('g').attr('transform', 'translate(' + margin + ', ' + margin + ')');\n\n  var line = d3.line().curve(d3.curveBasis).x(function (d) {\n    return x(d.x);\n  }).y(function (d) {\n    return y(d.y);\n  });\n\n  var path = g.append('path').attr('class', 'track').datum(track).attr('d', line).node();\n\n  var totalLength = path.getTotalLength();\n\n  var getCoords = function getCoords(pct) {\n    var p = path.getPointAtLength(totalLength * (pct % 1));\n    return [p.x, p.y];\n  };\n\n  var delta = 0.005;\n  var before = getCoords(1 - delta);\n  var zero = getCoords(0);\n  var after = getCoords(delta);\n\n  g.append('line').attr('class', 'finish-line').attr('x1', before[0]).attr('y1', before[1]).attr('x2', after[0]).attr('y2', after[1]).attr('transform', 'rotate(90, ' + zero + ')');\n\n  return { g: g, getCoords: getCoords };\n};\n\nexports.default = drawTrack;//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMS5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy9kcmF3VHJhY2suanM/MTY1OSJdLCJzb3VyY2VzQ29udGVudCI6WyJjb25zdCBkcmF3VHJhY2sgPSAoeyBjb250YWluZXIsIHRyYWNrIH0pID0+IHtcbiAgY29uc3QgbWFyZ2luID0gMTBcbiAgY29uc3QgZGltZW5zaW9uID0gY29udGFpbmVyLm5vZGUoKS5vZmZzZXRXaWR0aCAtIG1hcmdpbiAqIDJcblxuICBjb25zdCB4RXh0ZW50ID0gZDMuZXh0ZW50KHRyYWNrLCBkID0+IGQueClcbiAgY29uc3QgeCA9IGQzLnNjYWxlTGluZWFyKCkuZG9tYWluKHhFeHRlbnQpXG5cbiAgY29uc3QgeUV4dGVudCA9IGQzLmV4dGVudCh0cmFjaywgZCA9PiBkLnkpXG4gIGNvbnN0IHkgPSBkMy5zY2FsZUxpbmVhcigpLmRvbWFpbih5RXh0ZW50KVxuXG4gIGNvbnN0IGFzcGVjdCA9ICh4RXh0ZW50WzFdIC0geEV4dGVudFswXSkgLyAoeUV4dGVudFsxXSAtIHlFeHRlbnRbMF0pXG5cbiAgY29uc3Qgd2lkdGggPSBNYXRoLm1pbihkaW1lbnNpb24gKiBhc3BlY3QsIGRpbWVuc2lvbilcbiAgY29uc3QgaGVpZ2h0ID0gTWF0aC5taW4oZGltZW5zaW9uIC8gYXNwZWN0LCBkaW1lbnNpb24pXG5cbiAgeC5yYW5nZShbMCwgd2lkdGhdKVxuICB5LnJhbmdlKFswLCBoZWlnaHRdKVxuXG4gIGNvbnN0IHN2ZyA9IGNvbnRhaW5lclxuICAgIC5hcHBlbmQoJ3N2ZycpXG4gICAgLmF0dHIoJ3dpZHRoJywgd2lkdGggKyAyICogbWFyZ2luKVxuICAgIC5hdHRyKCdoZWlnaHQnLCBoZWlnaHQgKyAyICogbWFyZ2luKVxuXG4gIGNvbnN0IGcgPSBzdmcuYXBwZW5kKCdnJykuYXR0cigndHJhbnNmb3JtJywgYHRyYW5zbGF0ZSgke21hcmdpbn0sICR7bWFyZ2lufSlgKVxuXG4gIGNvbnN0IGxpbmUgPSBkM1xuICAgIC5saW5lKClcbiAgICAuY3VydmUoZDMuY3VydmVCYXNpcylcbiAgICAueChkID0+IHgoZC54KSlcbiAgICAueShkID0+IHkoZC55KSlcblxuICBjb25zdCBwYXRoID0gZ1xuICAgIC5hcHBlbmQoJ3BhdGgnKVxuICAgIC5hdHRyKCdjbGFzcycsICd0cmFjaycpXG4gICAgLmRhdHVtKHRyYWNrKVxuICAgIC5hdHRyKCdkJywgbGluZSlcbiAgICAubm9kZSgpXG5cbiAgY29uc3QgdG90YWxMZW5ndGggPSBwYXRoLmdldFRvdGFsTGVuZ3RoKClcblxuICBjb25zdCBnZXRDb29yZHMgPSBwY3QgPT4ge1xuICAgIGNvbnN0IHAgPSBwYXRoLmdldFBvaW50QXRMZW5ndGgodG90YWxMZW5ndGggKiAocGN0ICUgMSkpXG4gICAgcmV0dXJuIFtwLngsIHAueV1cbiAgfVxuXG4gIGNvbnN0IGRlbHRhID0gMC4wMDVcbiAgY29uc3QgYmVmb3JlID0gZ2V0Q29vcmRzKDEgLSBkZWx0YSlcbiAgY29uc3QgemVybyA9IGdldENvb3JkcygwKVxuICBjb25zdCBhZnRlciA9IGdldENvb3JkcyhkZWx0YSlcblxuICBnXG4gICAgLmFwcGVuZCgnbGluZScpXG4gICAgLmF0dHIoJ2NsYXNzJywgJ2ZpbmlzaC1saW5lJylcbiAgICAuYXR0cigneDEnLCBiZWZvcmVbMF0pXG4gICAgLmF0dHIoJ3kxJywgYmVmb3JlWzFdKVxuICAgIC5hdHRyKCd4MicsIGFmdGVyWzBdKVxuICAgIC5hdHRyKCd5MicsIGFmdGVyWzFdKVxuICAgIC5hdHRyKCd0cmFuc2Zvcm0nLCBgcm90YXRlKDkwLCAke3plcm99KWApXG5cbiAgcmV0dXJuIHsgZywgZ2V0Q29vcmRzIH1cbn1cblxuZXhwb3J0IGRlZmF1bHQgZHJhd1RyYWNrXG5cblxuXG4vLyBXRUJQQUNLIEZPT1RFUiAvL1xuLy8gZHJhd1RyYWNrLmpzIl0sIm1hcHBpbmdzIjoiOzs7OztBQUFBO0FBQUE7QUFBQTtBQUNBO0FBQUE7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUFBO0FBQ0E7QUFDQTtBQUNBO0FBQUE7QUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUlBO0FBQ0E7QUFDQTtBQUdBO0FBQUE7QUFDQTtBQUFBO0FBQ0E7QUFDQTtBQUNBO0FBTUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQVFBO0FBQ0E7QUFDQTtBQUNBIiwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///1\n")},function(module,exports,__webpack_require__){"use strict";eval("\n\nvar _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };\n\nvar _drawTrack2 = __webpack_require__(1);\n\nvar _drawTrack3 = _interopRequireDefault(_drawTrack2);\n\nvar _drawCars = __webpack_require__(0);\n\nvar _drawCars2 = _interopRequireDefault(_drawCars);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nvar container = d3.select('.circuit');\n\n// Draw the track.\n\nvar _drawTrack = (0, _drawTrack3.default)({ container: container, track: circuitTracks[0] }),\n    g = _drawTrack.g,\n    getCoords = _drawTrack.getCoords;\n\n// Create the state.\n\n\nvar state = {\n  car: {\n    position: 1\n  },\n  time: {\n    start: 0,\n    now: 0\n  }\n};\n\nvar formatTime = d3.timeFormat('%M:%S:%L');\n\nvar updateCounter = function updateCounter() {\n  var counterDiv = document.querySelector('.counter');\n  counterDiv.querySelector('.lap .value').textContent = Math.floor(state.car.position);\n  counterDiv.querySelector('.time .value').textContent = formatTime(state.time.now);\n};\n\nvar draw = function draw() {\n  // Draw the car.\n  (0, _drawCars2.default)({ g: g, getCoords: getCoords, cars: [state.car] });\n\n  // Update the counter\n  updateCounter();\n};\n\ndraw();\n\nvar tick = function tick(elapsed) {\n  var _state = state,\n      car = _state.car,\n      time = _state.time;\n\n  // Move the car.\n\n  var newPosition = car.position + 0.0003;\n\n  // Did we just start a new lap? (i.e. cross the finish line)\n  var isNewLap = car.position === 1 || Math.floor(car.position) !== Math.floor(newPosition);\n\n  // If we started a new lap, reset the time.\n  var start = isNewLap ? elapsed : time.start;\n\n  // Update the state.\n  state = _extends({}, state, {\n    car: _extends({}, car, {\n      position: newPosition\n    }),\n    time: _extends({}, time, {\n      start: start,\n      now: elapsed - start\n    })\n  });\n};\n\nd3.timer(function (elapsed) {\n  tick(elapsed);\n  draw();\n});//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMi5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy9zY3JpcHQuanM/OWE5NSJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgZHJhd1RyYWNrIGZyb20gJy4vZHJhd1RyYWNrLmpzJ1xuaW1wb3J0IGRyYXdDYXJzIGZyb20gJy4vZHJhd0NhcnMuanMnXG5cbmNvbnN0IGNvbnRhaW5lciA9IGQzLnNlbGVjdCgnLmNpcmN1aXQnKVxuXG4vLyBEcmF3IHRoZSB0cmFjay5cbmNvbnN0IHsgZywgZ2V0Q29vcmRzIH0gPSBkcmF3VHJhY2soeyBjb250YWluZXIsIHRyYWNrOiBjaXJjdWl0VHJhY2tzWzBdIH0pXG5cbi8vIENyZWF0ZSB0aGUgc3RhdGUuXG5sZXQgc3RhdGUgPSB7XG4gIGNhcjoge1xuICAgIHBvc2l0aW9uOiAxXG4gIH0sXG4gIHRpbWU6IHtcbiAgICBzdGFydDogMCxcbiAgICBub3c6IDBcbiAgfVxufVxuXG5jb25zdCBmb3JtYXRUaW1lID0gZDMudGltZUZvcm1hdCgnJU06JVM6JUwnKVxuXG5jb25zdCB1cGRhdGVDb3VudGVyID0gKCkgPT4ge1xuICBjb25zdCBjb3VudGVyRGl2ID0gZG9jdW1lbnQucXVlcnlTZWxlY3RvcignLmNvdW50ZXInKVxuICBjb3VudGVyRGl2LnF1ZXJ5U2VsZWN0b3IoJy5sYXAgLnZhbHVlJykudGV4dENvbnRlbnQgPSBNYXRoLmZsb29yKFxuICAgIHN0YXRlLmNhci5wb3NpdGlvblxuICApXG4gIGNvdW50ZXJEaXYucXVlcnlTZWxlY3RvcignLnRpbWUgLnZhbHVlJykudGV4dENvbnRlbnQgPSBmb3JtYXRUaW1lKFxuICAgIHN0YXRlLnRpbWUubm93XG4gIClcbn1cblxuY29uc3QgZHJhdyA9ICgpID0+IHtcbiAgLy8gRHJhdyB0aGUgY2FyLlxuICBkcmF3Q2Fycyh7IGcsIGdldENvb3JkcywgY2FyczogW3N0YXRlLmNhcl0gfSlcblxuICAvLyBVcGRhdGUgdGhlIGNvdW50ZXJcbiAgdXBkYXRlQ291bnRlcigpXG59XG5cbmRyYXcoKVxuXG5jb25zdCB0aWNrID0gZWxhcHNlZCA9PiB7XG4gIGNvbnN0IHsgY2FyLCB0aW1lIH0gPSBzdGF0ZVxuXG4gIC8vIE1vdmUgdGhlIGNhci5cbiAgY29uc3QgbmV3UG9zaXRpb24gPSBjYXIucG9zaXRpb24gKyAwLjAwMDNcblxuICAvLyBEaWQgd2UganVzdCBzdGFydCBhIG5ldyBsYXA/IChpLmUuIGNyb3NzIHRoZSBmaW5pc2ggbGluZSlcbiAgY29uc3QgaXNOZXdMYXAgPVxuICAgIGNhci5wb3NpdGlvbiA9PT0gMSB8fCBNYXRoLmZsb29yKGNhci5wb3NpdGlvbikgIT09IE1hdGguZmxvb3IobmV3UG9zaXRpb24pXG5cbiAgLy8gSWYgd2Ugc3RhcnRlZCBhIG5ldyBsYXAsIHJlc2V0IHRoZSB0aW1lLlxuICBjb25zdCBzdGFydCA9IGlzTmV3TGFwID8gZWxhcHNlZCA6IHRpbWUuc3RhcnRcblxuICAvLyBVcGRhdGUgdGhlIHN0YXRlLlxuICBzdGF0ZSA9IHtcbiAgICAuLi5zdGF0ZSxcbiAgICBjYXI6IHtcbiAgICAgIC4uLmNhcixcbiAgICAgIHBvc2l0aW9uOiBuZXdQb3NpdGlvblxuICAgIH0sXG4gICAgdGltZToge1xuICAgICAgLi4udGltZSxcbiAgICAgIHN0YXJ0LFxuICAgICAgbm93OiBlbGFwc2VkIC0gc3RhcnRcbiAgICB9XG4gIH1cbn1cblxuZDMudGltZXIoZWxhcHNlZCA9PiB7XG4gIHRpY2soZWxhcHNlZClcbiAgZHJhdygpXG59KVxuXG5cblxuLy8gV0VCUEFDSyBGT09URVIgLy9cbi8vIHNjcmlwdC5qcyJdLCJtYXBwaW5ncyI6Ijs7OztBQUFBO0FBQ0E7OztBQUFBO0FBQ0E7Ozs7O0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUFBO0FBQUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQURBO0FBQ0E7QUFDQTtBQURBO0FBR0E7QUFDQTtBQUNBO0FBRkE7QUFKQTtBQUNBO0FBU0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUdBO0FBR0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUFBO0FBQUE7QUFDQTtBQUVBO0FBQ0E7QUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUVBO0FBRUE7QUFGQTtBQUlBO0FBRUE7QUFDQTtBQUhBO0FBTkE7QUFZQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EiLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///2\n")}]);

drawCars.js

const drawCars = ({ g, cars, getCoords }) => {
  // join
  const circles = g.selectAll('circle.car').data(cars)

  // update
  circles
    .attr('transform', d => `translate(${getCoords(d.position)})`)

  // enter
  circles
    .enter()
    .append('circle')
    .attr('class', 'car')
    .attr('cx', 0)
    .attr('cy', 0)
    .attr('r', 4)
    .attr('transform', d => `translate(${getCoords(d.position)})`)

  circles.exit().remove()
}

export default drawCars

drawTrack.js

const drawTrack = ({ container, track }) => {
  const margin = 10
  const dimension = container.node().offsetWidth - margin * 2

  const xExtent = d3.extent(track, d => d.x)
  const x = d3.scaleLinear().domain(xExtent)

  const yExtent = d3.extent(track, d => d.y)
  const y = d3.scaleLinear().domain(yExtent)

  const aspect = (xExtent[1] - xExtent[0]) / (yExtent[1] - yExtent[0])

  const width = Math.min(dimension * aspect, dimension)
  const height = Math.min(dimension / aspect, dimension)

  x.range([0, width])
  y.range([0, height])

  const svg = container
    .append('svg')
    .attr('width', width + 2 * margin)
    .attr('height', height + 2 * margin)

  const g = svg.append('g').attr('transform', `translate(${margin}, ${margin})`)

  const line = d3
    .line()
    .curve(d3.curveBasis)
    .x(d => x(d.x))
    .y(d => y(d.y))

  const path = g
    .append('path')
    .attr('class', 'track')
    .datum(track)
    .attr('d', line)
    .node()

  const totalLength = path.getTotalLength()

  const getCoords = pct => {
    const p = path.getPointAtLength(totalLength * (pct % 1))
    return [p.x, p.y]
  }

  const delta = 0.005
  const before = getCoords(1 - delta)
  const zero = getCoords(0)
  const after = getCoords(delta)

  g
    .append('line')
    .attr('class', 'finish-line')
    .attr('x1', before[0])
    .attr('y1', before[1])
    .attr('x2', after[0])
    .attr('y2', after[1])
    .attr('transform', `rotate(90, ${zero})`)

  return { g, getCoords }
}

export default drawTrack

package.json

{
  "standard": {
    "globals": [
      "circuitTracks",
      "d3",
      "Vector"
    ]
  }
}

style.styl

$red = #8f092a

*
  box-sizing border-box
  
body
  background rgba(0, 0, 0, 0.1)
  font-family sans-serif
  font-size 0.85em

.circuit
  max-width 500px
  position relative
  margin 0 auto

  svg
    display block
    margin 0 auto
    
    path.track
      fill rgba(0, 0, 0, 0.1)
      stroke black
            
    .finish-line
      stroke black
      stroke-width 3px
      
  .counter
    position absolute
    bottom 0
    left 0