This simulates cars going around the Melbourne, Australia racing track. The circles are moving about three times faster than their real-life counterparts.
The car-circles change speed randomly, as displayed on the line chart.
See my previous work:
import drawTrack from './drawTrack.js'
import drawCars from './drawCars.js'
import constants from './constants.js'
import drawBoard from './drawBoard.js'
import chart from './chart.js'
import moveCar from './moveCar.js'
import calculateMetrics from './calculateMetrics.js'
// Draw the track.
const { g: gCircuit, getCoords } = drawTrack({
container: d3.select('.circuit'),
track: circuitTracks[0]
})
// Setup the chart.
const chartBits = chart.setup(d3.select('.chart'))
// Create the state.
const dx = 0.015
let state = {
cars: _(drivers)
.sortBy('standing')
.slice(0, 10)
.map((d, i, array) => ({
number: d.number,
name: d.name.split(' ')[1].slice(0, 3),
measures: [
{
speed: constants.AVERAGE,
pct: 0
},
{
speed: constants.AVERAGE,
pct: dx * array.length - (i + 1) * dx
}
],
elapsed: 0,
laps: [],
status: 'yellow',
position: i + 1
}))
.value()
}
const draw = setup => {
// Draw the cars.
drawCars({ g: gCircuit, getCoords, cars: state.cars })
// Draw the board.
drawBoard({ cars: state.cars, setup })
// Update the chart.
chart.update({ cars: state.cars, ...chartBits })
}
draw(true)
let now = 0
const timer = d3.interval(elapsed => {
const delta = elapsed - now
// Move the cars (and also keep them sorted by position).
state = {
...state,
cars: _(state.cars)
.map(car => moveCar({ car, delta }))
.sortBy(d => -_.last(d.measures).pct)
.value()
}
// Calculate metrics.
state = {
...state,
cars: state.cars.map((car, index, cars) =>
calculateMetrics({ car, raceElapsed: elapsed, index, cars })
)
}
// Draw everything.
draw()
now = elapsed
// Stop when the leader gets to 10 laps.
if (_.last(state.cars[0].measures).pct >= 10) {
timer.stop()
}
}, 0)
// document.querySelector('button.slow').addEventListener('click', () => {
// state.cars[0].speed = state.cars[0].speed * 0.9
// })
// document.querySelector('button.fast').addEventListener('click', () => {
// state.cars[0].speed = state.cars[0].speed * 1.01
// })
document.querySelector('button.stop').addEventListener('click', () => {
timer.stop()
})
<!DOCTYPE html>
<title>blockup</title>
<link href='https://fonts.googleapis.com/css?family=VT323' rel='stylesheet'>
<link href='dist.css' rel='stylesheet' />
<body>
<h1>speedy racer</h1>
<div class='circuit'></div>
<table class='board'>
<thead>
<tr>
<td></td>
<td></td>
<td></td>
<td class='cyan'>GAP</td>
<td class='cyan'>INT</td>
<td></td>
<td></td>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div class='chart'></div>
<button class='slow'>slow</button>
<button class='fast'>fast</button>
<button class='stop'>stop</button>
<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>
// We want:
// DONE: position
// DONE: status
// DONE: GAP
// DONE: INT
// DONE: recent lap time
// DONE: lap count
// : is better lap
// : is better GAP
// : is better INT
const formatGap = x => (x / 1000).toFixed(1)
const calculateMetrics = ({ car, raceElapsed, cars, index }) => {
const { elapsed, isNewLap, laps, measures, number, gap, status, int } = car
const leader = cars[0]
// Set defaults.
let previousLap = null
let newElapsed = elapsed
let newGap = gap
let newStatus = status
let newInt = int
// If the leader started a new lap,
// set this car's status to yellow.
if (leader.isNewLap) {
newStatus = 'yellow'
}
// If we started a new lap,
if (isNewLap) {
// do a number of things.
// Set our status to white.
newStatus = 'white'
// Calculate the previous lap time.
// To do this, do race elapsed - previous elapsed.
previousLap = raceElapsed - elapsed
// And set car's elapsed to race elapsed.
newElapsed = raceElapsed
// Every time you start a new lap,
// calculate GAP and INT.
// GAP = distance to the leader.
// INT = distance to the previous car.
// If we're the leader, ignore.
if (number === leader.number) {
newGap = 'LAP'
newInt = '--'
} else {
// If we're not the leader,
// calculate the lap count differences to leader and previous car.
const leaderLapDelta =
Math.floor(_.last(leader.measures).pct) -
Math.floor(_.last(measures).pct)
const previousCarLapDelta =
Math.floor(_.last(cars[index - 1].measures).pct) -
Math.floor(_.last(measures).pct)
// If we're on the same lap as leader, show time gap.
if (leaderLapDelta === 0) {
newGap = formatGap(newElapsed - leader.elapsed)
} else {
// If we're NOT on the same lap, just show the lap count delta.
newGap = `${leaderLapDelta}L`
}
// If we're on the same lap as previous, show time gap.
if (previousCarLapDelta === 0) {
newInt = formatGap(newElapsed - cars[index - 1].elapsed)
} else {
// If we're NOT on the same lap, just show the lap count delta.
newInt = `${previousCarLapDelta}L`
}
}
}
return {
...car,
elapsed: newElapsed,
laps: [...laps, previousLap].filter(d => d),
gap: newGap,
isNewLap,
status: newStatus,
int: newInt,
position: index + 1
}
}
export default calculateMetrics
import constants from './constants.js'
const setup = container => {
const margins = { top: 45, right: 5, bottom: 20, left: 40 }
const width = container.node().offsetWidth - margins.right - margins.left
const height = width / 3.5
const svg = container
.append('svg')
.attr('width', width + margins.right + margins.left)
.attr('height', height + margins.top + margins.bottom)
const g = svg
.append('g')
.attr('transform', `translate(${margins.left}, ${margins.top})`)
const x = d3
.scaleLinear()
.range([0, width])
.domain([0, 1])
const y = d3
.scaleLinear()
.range([height, 0])
.domain([0, constants.MAX])
.nice()
const line = d3
.line()
.curve(d3.curveBasis)
.x(d => x(d.pct % 1))
.y(d => y(d.speed))
const xAxis = g
.append('g')
.attr('class', 'axis axis--x')
.attr('transform', `translate(0, ${height})`)
.call(
d3
.axisBottom(x)
.tickSize(0)
.ticks([])
)
xAxis
.append('text')
.text('Start')
.attr('x', x(0))
.attr('text-anchor', 'start')
.attr('dy', 14)
xAxis
.append('text')
.text('Finish')
.attr('x', x(1))
.attr('text-anchor', 'end')
.attr('dy', 14)
const yAxis = g
.append('g')
.attr('class', 'axis axis--y')
.call(
d3
.axisLeft(y)
.tickSize(0)
.ticks(5)
)
yAxis
.append('text')
.text('(MPH)')
.attr('dx', -3)
.attr('dy', 6 - 18)
.attr('text-anchor', 'end')
yAxis
.append('text')
.text('Speed')
.attr('dx', -3)
.attr('dy', 6 - 18 - 18)
.attr('text-anchor', 'end')
return {
g,
x,
y,
line
}
}
const clean = measures => {
const lastTwo = measures.slice(-2).map(d => d.pct % 1)
return lastTwo[1] < lastTwo[0]
? measures.slice(0, measures.length - 1)
: measures
}
// _.last(measures).pct % 1 <
// measures.length === 2 ? measures.slice(-1) : measures
const getTransform = ({ d, x, y }) => {
const last = _.last(d.measures)
return `translate(${x(last.pct % 1)}, ${y(last.speed)})`
}
const update = ({ g, x, y, line, cars }) => {
// join
const paths = g.selectAll('path.car').data(cars, d => d.number)
// update
paths.attr('d', d => line(clean(d.measures)))
// enter
paths
.enter()
.append('path')
.attr('class', 'car')
.attr('d', d => line(clean(d.measures)))
// remove
paths.exit().remove()
// join
const gJoin = g.selectAll('g.car').data(cars, d => d.number)
// update
gJoin
.attr('class', d => (d.position === 1 ? 'car leader' : 'car'))
.attr('transform', d => getTransform({ d, x, y }))
// enter
const gEnter = gJoin
.enter()
.append('g')
.attr('class', d => (d.position === 1 ? 'car leader' : 'car'))
.attr('transform', d => getTransform({ d, x, y }))
gEnter
.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('r', 9)
gEnter
.append('text')
.text(d => d.number)
.attr('dy', 4)
// remove
gJoin.exit().remove()
}
const chart = {
setup,
update
}
export default chart
const multiplier = 1
const constants = {
MIN: 60 * multiplier,
AVERAGE: 140 * multiplier,
MAX: 194 * multiplier
}
export default constants
*{box-sizing:border-box}html{background:rgba(1,11,20,.5);padding:0;margin:0}body{background:#010b14;font-family:VT323,monospace;overflow:hidden;margin:0 auto;padding:0;position:relative;width:960px;height:500px}svg{display:block}h1{color:#dacbed;text-shadow:0 0 14px rgba(218,203,237,.75);text-transform:uppercase;font-weight:400;font-style:italic;position:absolute;width:100%;text-align:center;padding:0;margin:0;font-size:2.5em}.chart{width:50%;position:absolute;bottom:1em;right:.75em}.chart svg{shape-rendering:crispEdges;-webkit-filter:drop-shadow(0 0 14px rgba(15,208,254,.35));filter:drop-shadow(0 0 14px rgba(15,208,254,.35))}.chart svg path.car{fill:none;stroke:#0fd0fe}.chart svg g.car.leader circle{stroke:#fa6354}.chart svg g.car.leader text{fill:#fa6354}.chart svg g.car circle{fill:none;fill:#010b14;stroke:#dacbed}.chart svg g.car text{font-size:.9em;text-anchor:middle;fill:#dacbed}.chart svg .axis path{stroke:#018dae;stroke-dasharray:1 10}.chart svg .axis text{font-size:1.75em;font-family:VT323,monospace;fill:#018dae}.circuit{width:50%;position:relative;margin:0;padding:.5em 0 0 0}.circuit svg{margin:0 auto;shape-rendering:crispEdges;-webkit-filter:drop-shadow(0 0 14px rgba(15,208,254,.35));filter:drop-shadow(0 0 14px rgba(15,208,254,.35))}.circuit svg path.track{fill:none;stroke:#0fd0fe}.circuit svg .finish-line{stroke:#0fd0fe;stroke-width:2px}.circuit svg g.car.leader path{stroke:#fa6354}.circuit svg g.car.leader text{fill:#fa6354}.circuit svg g.car path{fill:#010b14;stroke:#dacbed}.circuit svg g.car text{font-size:1.125em;text-anchor:middle;fill:#dacbed}button{display:none}table{position:absolute;table-layout:fixed;font-size:1.125em;top:1.5em;right:1em;z-index:1;color:#0fd0fe;text-align:right;padding:.5em}table td,table th{padding:0 .75em}table th{font-weight:400}table td{color:#f6df83;text-shadow:0 0 14px rgba(246,223,131,.75)}table .leader{color:#fa6354;text-shadow:0 0 14px rgba(250,99,84,.75)}table .cyan{color:#018dae;text-shadow:0 0 14px rgba(1,141,174,.75)}table .white{color:#dacbed;text-shadow:0 0 14px rgba(218,203,237,.75)}table tbody tr td:nth-child(1){color:#018dae;text-shadow:0 0 14px rgba(1,141,174,.75);width:2.5em}table tbody tr td:nth-child(2){width:2.5em}table tbody tr td:nth-child(3){text-align:left;text-transform:uppercase;width:3.5em}table tbody tr td:nth-child(4){width:4em}table tbody tr td:nth-child(5){width:4em}table tbody tr td:nth-child(6){width:6em}table tbody tr td:nth-child(7){width:2.5em}
const formatTime = d3.timeFormat('%-M:%S.%L')
const rowCells = d => [
['', d.position],
[d.position === 1 ? 'leader' : d.status, d.number],
[d.position === 1 ? 'leader' : d.status, d.name],
['', d.gap || ''],
['', d.int || ''],
['', _.last(d.laps) ? formatTime(_.last(d.laps)) : ''],
['', Math.floor(_.last(d.measures).pct) || '']
]
const drawBoard = ({ cars, setup }) => {
// console.log(JSON.stringify(cars, null, 2))
// Only draw the board when a car starts a new lap.
if (cars.filter(d => d.isNewLap).length || setup) {
const tbody = d3.select('table.board tbody')
// join
const trJoin = tbody.selectAll('tr').data(cars)
// update
trJoin
.selectAll('td')
.data(rowCells)
.attr('class', d => d[0])
// immediately display non-new laps
trJoin
.transition()
.duration(0)
.filter(d => !d.isNewLap)
.selectAll('td')
.text(d => d[1])
.style('visibility', 'visible')
// stagger display new laps
trJoin
.transition()
.duration(0)
.filter(d => d.isNewLap)
.selectAll('td')
.text(d => d[1])
.style('visibility', 'hidden')
.transition()
.delay((d, i) => 125 + i * 125)
.style('visibility', 'visible')
// enter
const trEnter = trJoin.enter().append('tr')
trEnter
.selectAll('td')
.data(rowCells)
.enter()
.append('td')
.attr('class', d => d[0])
.text(d => d[1])
}
}
export default drawBoard
const getTriangleTransform = d =>
`rotate(${-d.coords.angle + 90} 0 0) scale(1 1.2)`
const getGTransform = d => `translate(${d.coords})`
const drawCars = ({ g, cars, getCoords }) => {
const data = cars.map(car => ({
...car,
coords: getCoords(_.last(car.measures).pct)
}))
// join
const gJoin = g.selectAll('g.car').data(data, d => d.number)
// update
gJoin
.attr('class', d => (d.position === 1 ? 'car leader' : 'car'))
.attr('transform', getGTransform)
.select('path')
.attr('transform', getTriangleTransform)
// enter
const gEnter = gJoin
.enter()
.append('g')
.attr('class', d => (d.position === 1 ? 'car leader' : 'car'))
.attr('transform', getGTransform)
gEnter
.append('path')
.attr('d', d =>
d3
.symbol()
.type(d3.symbolTriangle)
.size(400)()
)
.attr('transform', getTriangleTransform)
gEnter
.append('text')
.text(d => d.number)
.attr('dy', 5)
// remove
gJoin.exit().remove()
}
export default drawCars
import getAngle from './getAngle.js'
const drawTrack = ({ container, track }) => {
const margin = 20
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 length = totalLength * (pct % 1)
const delta = 1
const offset = length + 1 > totalLength ? -delta : delta
const axy = path.getPointAtLength(length)
const bxy = path.getPointAtLength(length + offset)
const a = [axy.x, axy.y]
const b = [bxy.x, bxy.y]
const angle = getAngle([a, b])
const array = [...a]
array.angle = angle
return array
}
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 getAngle = ([a, b]) => {
const x = b[0] - a[0]
const y = -(b[1] - a[1])
const angle = Math.atan2(y, x) * 180 / Math.PI
return angle
}
export default getAngle
const getRandomArbitrary = (min, max) => Math.random() * (max - min) + min
export default getRandomArbitrary
import constants from './constants.js'
import getRandomArbitrary from './getRandomArbitrary.js'
const moveCar = ({ car, delta }) => {
const { measures } = car
const random = 0.02
const lastTwo = measures.slice(-2)
// Figure out if we started a new lap.
const isNewLap = Math.floor(lastTwo[0].pct) !== Math.floor(lastTwo[1].pct)
// Calculate new speed.
const newSpeed = _.clamp(
lastTwo[1].speed * getRandomArbitrary(1 - random, 1 + random),
constants.MIN,
constants.MAX
)
// Calculate new pct.
const newPct = lastTwo[1].pct + delta * lastTwo[1].speed / 3600000
return {
...car,
isNewLap,
measures: [
...measures,
{
pct: newPct,
speed: newSpeed
}
].slice(isNewLap ? -2 : 0)
}
}
export default moveCar
{
"standard": {
"globals": [
"drivers",
"circuitTracks",
"d3",
"_"
]
}
}
$black = #010b14
$white = #dacbed
$blue = #0fd0fe
$yellow = #f6df83
$lightblue = darken($blue, 35%)
$red = lighten(#f11c07, 33%)
$text-blur-radius = 14px
$text-blur-opacity = 0.75
$svg-blur-opacity = 0.35
$font-family = 'VT323', monospace
*
box-sizing border-box
html
background alpha($black, 0.5)
padding 0
margin 0
body
background $black
font-family $font-family
overflow hidden
margin 0 auto
padding 0
position relative
width 960px
height 500px
// border solid $white 1px
svg
display block
h1
// visibility hidden
color $white
text-shadow 0 0 $text-blur-radius alpha($white, $text-blur-opacity)
text-transform uppercase
font-weight normal
font-style italic
position absolute
width 100%
text-align center
padding 0
margin 0
font-size 2.5em
.chart
width 50%
position absolute
bottom 1em
right 0.75em
svg
shape-rendering crispEdges
filter drop-shadow(0 0 $text-blur-radius alpha($blue, $svg-blur-opacity))
path.car
fill none
stroke $blue
g.car
&.leader
circle
stroke $red
text
fill $red
circle
fill none
fill $black
stroke $white
text
font-size 0.9em
text-anchor middle
fill $white
.axis
path
stroke $lightblue
stroke-dasharray 1 10
text
font-size 1.75em
font-family $font-family
fill $lightblue
.circuit
width 50%
position relative
margin 0
padding 0.5em 0 0 0
svg
margin 0 auto
shape-rendering crispEdges
filter drop-shadow(0 0 $text-blur-radius alpha($blue, $svg-blur-opacity))
path.track
fill none
stroke $blue
.finish-line
stroke $blue
stroke-width 2px
g.car
&.leader
path
stroke $red
text
fill $red
path
fill $black
stroke $white
text
font-size 1.125em
text-anchor middle
fill $white
button
display none
table
// visibility hidden
position absolute
table-layout fixed
font-size 1.125em
top 1.5em
right 1em
z-index 1
color $blue
text-align right
padding 0.5em
td, th
padding 0 0.75em
th
font-weight normal
td
color $yellow
text-shadow 0 0 $text-blur-radius alpha($yellow, $text-blur-opacity)
.leader
color $red
text-shadow 0 0 $text-blur-radius alpha($red, $text-blur-opacity)
.cyan
color $lightblue
text-shadow 0 0 $text-blur-radius alpha($lightblue, $text-blur-opacity)
.white
color $white
text-shadow 0 0 $text-blur-radius alpha($white, $text-blur-opacity)
tbody
tr
td:nth-child(1)
color $lightblue
text-shadow 0 0 $text-blur-radius alpha($lightblue, $text-blur-opacity)
width 2.5em
td:nth-child(2)
width 2.5em
td:nth-child(3)
text-align left
text-transform uppercase
width 3.5em
td:nth-child(4)
width 4em
td:nth-child(5)
width 4em
td:nth-child(6)
width 6em
td:nth-child(7)
width 2.5em