block by gabrielflorit 277f3c61422c0e73c3f7720b4f569bbd

Lap cars timings board

Full Screen

Made with blockup

TODO

script.js

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

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

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

// Create the state.
let state = {
  cars: _(drivers)
    .sortBy('standing')
    .reverse()
    .map((d, i) => ({
      number: d.number,
      name: d.name.split(' ')[1].slice(0, 3),
      speed: 180,
      standing: d.standing,
      pct: i * 0.00125,
      elapsed: 0,
      status: 'white'
    }))
    .value()
}

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

  // Draw the board.
  drawBoard(state.cars)
}

let now = 0
const timer = d3.timer(elapsed => {
  const delta = elapsed - now

  // Move the cars.
  state = {
    ...state,
    cars: moveCars({
      cars: state.cars,
      delta,
      elapsed
    })
  }

  // Draw everything.
  draw()

  now = elapsed
})

// if (state.cars[4].pct >= 2) {
//   // timer.stop()
// }})

index.html

<!DOCTYPE html>
<title>blockup</title>
<link href='dist.css' rel='stylesheet' />
<body>
  <table class='board'></table>
  <div class='circuit'></div>
  <script src='lodash.min.js'></script>
	<script src='d3.v4.min.js'></script>
  <script src='drivers.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 svg g.car circle{fill:#000}.circuit svg g.car text{font-size:.8em;text-anchor:middle;fill:#fff}table{position:absolute;font-size:.9em;top:10px;right:10px;z-index:1;background:#000;color:#fff;text-align:right;padding:.5em}table td,table th{padding:0 .75em}table th{font-weight:400;color:#fff}table td{color:#ff0}table .cyan{color:#0ff}table .white{color:#fff}table .text{text-align:left;text-transform:uppercase}

drawBoard.js

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

const drawBoard = cars => {
  // console.log(JSON.stringify(cars, null, 2))
  const headers = `
    <tr>
      <th></th>
      <th></th>
      <th></th>
      <th class='cyan'>GAP</th>
      <th></th>
      <th></th>
    </tr>
  `

  const rows = _(cars)
    .map(
      (d, i) => `
      <tr>
        <td class='cyan'>${i + 1}</td>
        <td class='${d.status}'>${d.number}</td>
        <td class='text ${d.status}'>${d.name}</td>
        <td>${d.gapForDisplay || ''}</td>
        <td class='${d.isBetterLap ? 'white' : 'yellow'}'>${d.lap
  ? formatTime(d.lap)
  : ''}</td>
        <td>${Math.floor(d.pct)}</td>
      </tr>`
    )
    .value()
    .join('')

  document.querySelector('table.board').innerHTML = headers + rows
}

export default drawBoard

drawCars.js

const drawCars = ({ g, cars, getCoords }) => {
  // join
  const gJoin = g.selectAll('g.car').data(cars, d => d.number)

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

  // enter
  const gEnter = gJoin
    .enter()
    .append('g')
    .attr('class', 'car')
    .attr('transform', d => `translate(${getCoords(d.pct)})`)

  gEnter
    .append('circle')
    .attr('cx', 0)
    .attr('cy', 0)
    .attr('r', 8)

  gEnter
    .append('text')
    .text(d => d.number)
    .attr('dy', 4)

  // remove
  gJoin.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.01
  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

drivers.js

var drivers = [
  {
    entrant: 'Scuderia Ferrari',
    constructor: 'Ferrari',
    chassis: 'TBA',
    power: 'Ferrari',
    tyres: 'P',
    number: '5',
    standing: 2,
    name: 'Sebastian Vettel'
  },
  {
    entrant: 'Scuderia Ferrari',
    constructor: 'Ferrari',
    chassis: 'TBA',
    power: 'Ferrari',
    tyres: 'P',
    number: '7',
    standing: 4,
    name: 'Kimi Räikkönen'
  },
  {
    entrant: 'Sahara Force India F1 Team',
    constructor: 'Force India-Mercedes',
    chassis: 'TBA',
    power: 'Mercedes',
    tyres: 'P',
    number: '11',
    standing: 7,
    name: 'Sergio Pérez'
  },
  {
    entrant: 'Sahara Force India F1 Team',
    constructor: 'Force India-Mercedes',
    chassis: 'TBA',
    power: 'Mercedes',
    tyres: 'P',
    number: '31',
    standing: 8,
    name: 'Esteban Ocon'
  },
  {
    entrant: 'Haas F1 Team',
    constructor: 'Haas-Ferrari',
    chassis: 'TBA',
    power: 'Ferrari',
    tyres: 'P',
    number: '8',
    standing: 13,
    name: 'Romain Grosjean'
  },
  {
    entrant: 'Haas F1 Team',
    constructor: 'Haas-Ferrari',
    chassis: 'TBA',
    power: 'Ferrari',
    tyres: 'P',
    number: '20',
    standing: 14,
    name: 'Kevin Magnussen'
  },
  {
    entrant: 'McLaren F1 Team',
    constructor: 'McLaren-Renault',
    chassis: 'TBA',
    power: 'Renault',
    tyres: 'P',
    number: '2',
    standing: 16,
    name: 'Stoffel Vandoorne'
  },
  {
    entrant: 'McLaren F1 Team',
    constructor: 'McLaren-Renault',
    chassis: 'TBA',
    power: 'Renault',
    tyres: 'P',
    number: '14',
    standing: 15,
    name: 'Fernando Alonso'
  },
  {
    entrant: 'Mercedes AMG Petronas Motorsport',
    constructor: 'Mercedes',
    chassis: 'TBA',
    power: 'Mercedes',
    tyres: 'P',
    number: '44',
    standing: 1,
    name: 'Lewis Hamilton'
  },
  {
    entrant: 'Mercedes AMG Petronas Motorsport',
    constructor: 'Mercedes',
    chassis: 'TBA',
    power: 'Mercedes',
    tyres: 'P',
    number: '77',
    standing: 3,
    name: 'Valtteri Bottas'
  },
  {
    entrant: 'Aston Martin Red Bull Racing',
    constructor: 'Red Bull Racing-TAG Heuer',
    chassis: 'RB14',
    power: 'TAG Heuer',
    tyres: 'P',
    number: '3',
    standing: 5,
    name: 'Daniel Ricciardo'
  },
  {
    entrant: 'Aston Martin Red Bull Racing',
    constructor: 'Red Bull Racing-TAG Heuer',
    chassis: 'RB14',
    power: 'TAG Heuer',
    tyres: 'P',
    number: '33',
    standing: 6,
    name: 'Max Verstappen'
  },
  {
    entrant: 'Renault Sport Formula One Team',
    constructor: 'Renault',
    chassis: 'TBA',
    power: 'Renault',
    tyres: 'P',
    number: '27',
    standing: 10,
    name: 'Nico Hülkenberg'
  },
  {
    entrant: 'Renault Sport Formula One Team',
    constructor: 'Renault',
    chassis: 'TBA',
    power: 'Renault',
    tyres: 'P',
    number: '55',
    standing: 9,
    name: 'Carlos Sainz Jr.'
  },
  {
    entrant: 'Alfa Romeo Sauber F1 Team',
    constructor: 'Sauber-Ferrari',
    chassis: 'TBA',
    power: 'Ferrari',
    tyres: 'P',
    number: '9',
    standing: 20,
    name: 'Marcus Ericsson'
  },
  {
    entrant: 'Alfa Romeo Sauber F1 Team',
    constructor: 'Sauber-Ferrari',
    chassis: 'TBA',
    power: 'Ferrari',
    tyres: 'P',
    number: '16',
    standing: 26,
    name: 'Charles Leclerc'
  },
  {
    entrant: 'Red Bull Toro Rosso Honda',
    constructor: 'Scuderia Toro Rosso-Honda',
    chassis: 'TBA',
    power: 'Honda',
    tyres: 'P',
    number: '10',
    standing: 21,
    name: 'Pierre Gasly'
  },
  {
    entrant: 'Red Bull Toro Rosso Honda',
    constructor: 'Scuderia Toro Rosso-Honda',
    chassis: 'TBA',
    power: 'Honda',
    tyres: 'P',
    number: '28',
    standing: 23,
    name: 'Brendon Hartley'
  },
  {
    entrant: 'Williams Martini Racing',
    constructor: 'Williams-Mercedes',
    chassis: 'TBA',
    power: 'Mercedes',
    tyres: 'P',
    number: '18',
    standing: 12,
    name: 'Lance Stroll'
  }
]

getRandomArbitrary.js

const getRandomArbitrary = (min, max) => Math.random() * (max - min) + min

export default getRandomArbitrary

moveCars.js

import getRandomArbitrary from './getRandomArbitrary.js'

const formatGap = x => (x / 1000).toFixed(1)

const moveCars = ({ cars, delta, elapsed }) => {
  // First, move the cars.
  // This movement will give us a leader.
  // Next, calculate car metrics.
  const movedCars = _(cars)
    .map(car => moveCar({ car, delta }))
    .sortBy('pct')
    .reverse()
    .value()

  const metricdCars = movedCars.map(car =>
    calculateMetrics({ car, raceElapsed: elapsed, leader: movedCars[0] })
  )

  const isNewLeaderLap = movedCars[0].isNewLap

  // If the leader has started a new lap,
  // set all the other car names to yellow.
  const statusedCars = metricdCars.map((car, i) => ({
    ...car,
    status: isNewLeaderLap && i > 0 ? 'yellow' : car.status
  }))

  return statusedCars
}

const moveCar = ({ car, delta }) => {
  const { pct, speed } = car
  const newPct = pct + delta * speed / 3600000
  const isNewLap = Math.floor(newPct) !== Math.floor(pct)

  // If the car has started a new lap,
  // set its status to white.
  // Otherwise keep the existing status.
  const status = isNewLap ? 'white' : car.status
  const speedRandomizer = 0.02

  return {
    ...car,
    pct: newPct,
    isNewLap,
    status,
    speed:
      car.speed * getRandomArbitrary(1 - speedRandomizer, 1 + speedRandomizer)
  }
}

const calculateMetrics = ({ car, raceElapsed, leader }) => {
  const { isNewLap, pct } = car
  let {
    elapsed,
    lap,
    bestLap,
    gapForDisplay,
    gap,
    previousGap,
    isBetterLap,
    isBetterGap
  } = car

  // Did the car start a new lap?
  if (isNewLap) {
    // Yes.
    // Calculate its lap time.
    // To do this, do race elapsed - previous elapsed.
    lap = raceElapsed - elapsed

    // And set car's elapsed to race elapsed.
    elapsed = raceElapsed

    // Now update the best lap, if necessary.
    // If we don't have a best lap, use this one.
    if (!bestLap) {
      bestLap = lap
    }

    // See if this lap beats the best lap.
    bestLap = Math.min(lap, bestLap)

    const previousLap = car.lap

    // Calculate if this a better lap than the previous one.
    isBetterLap = lap < previousLap

    // Calculate GAP:
    // if we're the leader, ignore.
    // (But firstly, save the previous gap.)
    previousGap = car.gap
    if (car.number === leader.number) {
      gap = 0
      gapForDisplay = 'LAP'
      isBetterGap = false
    } else {
      // If we're not the leader,
      // calculate the lap count difference,
      const lapCountDelta = Math.floor(leader.pct) - Math.floor(pct)

      // and the actual gap.
      gap = elapsed - leader.elapsed

      // If we're on the same lap, show the time gap.
      if (lapCountDelta === 0) {
        gapForDisplay = formatGap(elapsed - leader.elapsed)
      } else {
        // If we're NOT on the same lap, just show the lap count delta.
        gapForDisplay = `${lapCountDelta}L`
      }

      // Finally, calculate GAP status.
      isBetterGap = gap < previousGap
    }
  } else {
    // No.
    // Do nothing.
  }

  return {
    ...car,
    elapsed,
    lap,
    bestLap,
    isBetterLap,
    isBetterGap,
    previousGap,
    gap,
    gapForDisplay
  }
}

export default moveCars

package.json

{
  "standard": {
    "globals": [
      "drivers",
      "circuitTracks",
      "d3",
      "_"
    ]
  }
}

style.styl

$red = #8f092a
$blue = rgba(0,111,145,1)

*
  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
      
    g.car
      
      circle
        fill black
          
      text
        font-size 0.8em
        text-anchor middle
        fill white
        
table
  position absolute
  font-size 0.9em
  top 10px
  right 10px
  z-index 1
  background black
  color white
  text-align right
  padding 0.5em
  td, th
    padding 0 0.75em
  th
    font-weight normal
    color white
  td
    color yellow
  .cyan
    color cyan
  .white
    color white
  .text
    text-align left
    text-transform uppercase