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()
// }})
<!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>
*{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}
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
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
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
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'
}
]
const getRandomArbitrary = (min, max) => Math.random() * (max - min) + min
export default getRandomArbitrary
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
{
"standard": {
"globals": [
"drivers",
"circuitTracks",
"d3",
"_"
]
}
}
$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