|
<!DOCTYPE html> |
|
<meta charset="utf-8"> |
|
<style> |
|
/** |
|
* Copied from Google Fonts - https://fonts.googleapis.com/css?family=Lato |
|
* Modified to add font-display: swap; |
|
*/ |
|
@font-face { |
|
font-family: 'Lato'; |
|
font-style: normal; |
|
font-weight: 400; |
|
src: local('Lato Regular'), local('Lato-Regular'), url(https://fonts.gstatic.com/s/lato/v14/S6uyw4BMUTPHjx4wXiWtFCc.woff2) format('woff2'); |
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; |
|
font-display: swap; |
|
} |
|
body { |
|
font-family: Lato, 'Open Sans', sans-serif; |
|
margin: 0; |
|
} |
|
svg { |
|
max-height: 100vh; |
|
max-width: 100vw; |
|
} |
|
.day { |
|
stroke: rgba(255, 255, 255, 0.5); |
|
stroke-width: 0.5; |
|
} |
|
.flight-duration { |
|
fill: hsl(193, 42%, 44%); |
|
} |
|
.flight-marker-line { |
|
stroke: hsl(193, 42%, 44%); |
|
} |
|
.flight-marker-alt .flight-marker-line { |
|
stroke-dasharray: 2 2; |
|
} |
|
.flight-text { |
|
font-size: 14px; |
|
fill: #fff; |
|
} |
|
.flight-duration-text { |
|
font-size: 11px; |
|
} |
|
.flight-time-text { |
|
font-size: 11px; |
|
} |
|
.flight-marker-alt .flight-time-text { |
|
fill: #999; |
|
} |
|
.location-text { |
|
font-size: 14px; |
|
} |
|
</style> |
|
<body> |
|
<div id="container"></div> |
|
|
|
<script src="https://d3js.org/d3.v4.min.js"></script> |
|
<script> |
|
// Config |
|
const dayWidth = 200; |
|
const dayHeight = 20; |
|
const padding = 15; |
|
const labelLeftWidth = 90; |
|
|
|
const colours = { |
|
midnight: 'hsl(251, 29%, 24%)', |
|
night: 'hsl(251, 59%, 29%)', |
|
day: 'hsl(40, 90%, 66%)', |
|
noon: 'hsl(40, 96%, 88%)', |
|
flight1: 'hsl(193, 52%, 54%)', |
|
flight2: 'hsl(173, 42%, 24%)', |
|
} |
|
|
|
// Calculated values |
|
const oneDayInMinutes = 24 * 60; |
|
const timeScale = d3.scaleLinear() |
|
.domain([0, oneDayInMinutes]) |
|
.range([0, dayWidth]); |
|
|
|
const departureY = padding; |
|
const flightY = departureY + dayHeight + padding; |
|
const arrivalY = flightY + dayHeight + padding; |
|
let totalHeight = arrivalY + dayHeight + padding; |
|
let totalWidth = padding * 2 + labelLeftWidth; |
|
|
|
d3.selection.prototype.translate = function (x, y) { |
|
const xfn = typeof x === 'function' ? x : () => x; |
|
const yfn = typeof y === 'function' ? y : () => y; |
|
return this.attr('transform', function (...args) { |
|
let tx = xfn.apply(this, args); |
|
let ty = yfn.apply(this, args); |
|
return `translate(${tx}, ${ty})`; |
|
}); |
|
}; |
|
|
|
const dom = {}; |
|
dom.root = d3.select('#container').append('svg'); |
|
dom.defs = dom.root.append('defs'); |
|
dom.labels = dom.root.append('g').attr('class', 'labels'); |
|
dom.locations = dom.root.append('g').attr('class', 'locations'); |
|
dom.departure = dom.locations.append('g').attr('class', 'days'); |
|
dom.arrival = dom.locations.append('g').attr('class', 'days'); |
|
dom.days = dom.locations.selectAll('.days'); |
|
dom.flights = dom.root.append('g').attr('class', 'flights'); |
|
|
|
// Pad time parts to 2 digits |
|
function pad(num) { |
|
return ('00' + num).slice(-2); |
|
} |
|
|
|
// Converts a time string to minutes, naively assuming well-formatted input. |
|
// Time string can optionally include seconds. |
|
// e.g. 03:15 -> 195 |
|
// e.g. 03:15:15 -> 195.25 |
|
function timeToMinutes(timeStr) { |
|
const parts = timeStr.split(':').map(function (part) { |
|
return parseInt(part, 10); |
|
}); |
|
const seconds = parts.length > 2 ? parts[2] / 60 : 0; |
|
return parts[0] * 60 + parts[1] + seconds; |
|
} |
|
|
|
// Inverse of timeToMinutes, naively assuming positive numbers. |
|
// e.g. 195.25 -> 03:15:15 |
|
function minutesToTime(minutes) { |
|
let parts = [ |
|
Math.floor(minutes / 60), |
|
Math.floor(minutes % 60), |
|
]; |
|
const seconds = minutes % 1; |
|
if (seconds > 0) { |
|
parts.push(seconds); |
|
} |
|
return parts.map(pad).join(':'); |
|
} |
|
|
|
function convertTime(time, fromOffset, toOffset) { |
|
const toTime = (time - fromOffset + toOffset) % oneDayInMinutes; |
|
return minutesToTime(toTime); |
|
} |
|
|
|
const rTimePart = /T([\d:]+)(?:Z|\+)/; |
|
|
|
function processSunTimes(location) { |
|
// Assumes all sun times are in UTC |
|
const extractTime = (isoString) => { |
|
const match = isoString.match(rTimePart); |
|
if (!match) { |
|
return 0; |
|
} |
|
return (timeToMinutes(match[1]) + location.curOffset) % oneDayInMinutes; |
|
} |
|
|
|
return { |
|
startTwilight: extractTime(location.sun.astronomical_twilight_begin), |
|
sunrise: extractTime(location.sun.sunrise), |
|
solarNoon: extractTime(location.sun.solar_noon), |
|
sunset: extractTime(location.sun.sunset), |
|
endTwilight: extractTime(location.sun.astronomical_twilight_end), |
|
} |
|
} |
|
|
|
function sunTimesToStops(times) { |
|
return [ |
|
{ time: times.startTwilight, colour: 'night' }, |
|
{ time: times.sunrise, colour: 'day' }, |
|
{ time: times.solarNoon, colour: 'noon' }, |
|
{ time: times.sunset, colour: 'day' }, |
|
{ time: times.endTwilight, colour: 'night' }, |
|
] |
|
} |
|
|
|
d3.json('flight-tz-data.json', function (data) { |
|
// Calculate times |
|
const departLocal = timeToMinutes(data.from.time); |
|
let departUtc = departLocal - data.from.curOffset; |
|
const arriveLocal = timeToMinutes(data.to.time); |
|
let arriveUtc = arriveLocal - data.to.curOffset; |
|
let dayCount = 1; |
|
if (arriveUtc < departUtc) { |
|
arriveUtc += oneDayInMinutes; |
|
dayCount++; |
|
} |
|
const flightDuration = arriveUtc - departUtc; |
|
|
|
let departureX = 0; |
|
let arrivalX = 0; |
|
const offsetDiff = data.from.curOffset - data.to.curOffset; |
|
if (offsetDiff > 0) { |
|
arrivalX = timeScale(offsetDiff); |
|
} else { |
|
departureX = timeScale(-offsetDiff); |
|
} |
|
totalWidth += timeScale(dayCount * oneDayInMinutes + Math.abs(offsetDiff)); |
|
dom.root.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`); |
|
|
|
const departureSun = processSunTimes(data.from); |
|
const arrivalSun = processSunTimes(data.to); |
|
|
|
// Create sunrise/sunset fill patterns |
|
dom.sunGradients = dom.defs.selectAll('.def-sun') |
|
.data([departureSun, arrivalSun]) |
|
.enter().append('linearGradient') |
|
.attr('class', 'def-sun') |
|
.attr('id', (d, i) => `sun-${i}`) |
|
.attr('x1', 0) |
|
.attr('x2', '100%') |
|
.attr('y1', 0) |
|
.attr('y2', 0); |
|
dom.sunGradients.selectAll('stop') |
|
.data(d => sunTimesToStops(d)) |
|
.enter().append('stop') |
|
.attr('offset', d => d.time / oneDayInMinutes * 100 + '%') |
|
.attr('stop-color', d => colours[d.colour]); |
|
|
|
// Create flight shape fill pattern |
|
dom.flightGradient = dom.defs.append('linearGradient') |
|
.attr('id', 'flight-01') |
|
.attr('x1', 0) |
|
.attr('x2', '100%') |
|
.attr('y1', 0) |
|
.attr('y2', '100%') |
|
dom.flightGradient.selectAll('stop') |
|
.data([colours.flight1, colours.flight2]) |
|
.enter().append('stop') |
|
.attr('offset', (d, i) => i * 100 + '%') |
|
.attr('stop-color', d => d); |
|
|
|
|
|
/*** DRAW LOCATIONS ***/ |
|
|
|
// Draw left labels |
|
dom.labels.translate(padding, padding); |
|
dom.locationLabels = dom.labels.selectAll('.location-text') |
|
.data([data.from, data.to]) |
|
.enter().append('text') |
|
.attr('class', 'location-text') |
|
.text(d => `${d.name} (${d.code})`) |
|
.attr('x', 0).attr('y', (d, i) => (dayHeight + padding) * i * 2 + dayHeight / 2) |
|
.attr('dy', '0.35em'); |
|
|
|
// Draw days |
|
dom.locations.translate(labelLeftWidth, 0); |
|
dom.departure.translate(padding + departureX, departureY); |
|
dom.arrival.translate(padding + arrivalX, arrivalY); |
|
let days = [[], []]; |
|
for (i = 0; i < dayCount; i++) { |
|
const dayX = timeScale(oneDayInMinutes * i); |
|
days[0].push({ |
|
x: dayX, |
|
sun: 'sun-0', |
|
}); |
|
days[1].push({ |
|
x: dayX, |
|
sun: 'sun-1', |
|
}); |
|
} |
|
|
|
dom.days.data(days) |
|
.selectAll('.day').data(d => d) |
|
.enter().append('rect') |
|
.attr('class', 'day') |
|
.attr('x', 0).attr('y', 0) |
|
.attr('width', timeScale(oneDayInMinutes)) |
|
.attr('height', dayHeight) |
|
.attr('rx', 2) |
|
.style('fill', d => `url(#${d.sun})`) |
|
.translate(d => d.x, 0); |
|
|
|
|
|
/*** DRAW FLIGHT ***/ |
|
|
|
dom.flight = dom.flights.append('g') |
|
.attr('class', 'flight') |
|
.translate(padding + labelLeftWidth + timeScale(departLocal), flightY); |
|
|
|
const flightWidth = timeScale(arriveUtc - departUtc); |
|
const rx = 15, ry = 20; |
|
const fxL = 0.5, fxR = flightWidth - 0.5; |
|
const fyM1 = 0; |
|
const fyM2 = dayHeight; |
|
const fyT = -padding - dayHeight / 2; |
|
const fyB = dayHeight * 1.5 + padding; |
|
|
|
function createMarker(selection) { |
|
const root = selection.append('g'); |
|
|
|
// Calculate x/y values |
|
root.datum((d) => { |
|
const [lr, tb, text] = d; |
|
const isLeft = lr === 'left'; |
|
const isTop = tb === 'top'; |
|
const corner = lr[0] + tb[0]; |
|
const x = isLeft ? fxL : fxR; |
|
const y1 = isTop ? fyT : (isLeft ? fyM1 : fyM2); |
|
const y2 = isTop ? (isLeft ? fyM1 : fyM2) : fyB; |
|
|
|
return { corner, isLeft, isTop, x, y1, y2, text }; |
|
}); |
|
root.attr('class', 'flight-marker') |
|
.classed('flight-marker-alt', d => d.corner === 'lb' || d.corner === 'rt'); |
|
|
|
// Line |
|
root.append('line') |
|
.attr('class', d => 'flight-marker-line') |
|
.attr('x1', d => d.x) |
|
.attr('x2', d => d.x) |
|
.attr('y1', d => d.y1) |
|
.attr('y2', d => d.y2); |
|
|
|
// Label |
|
root.append('text') |
|
.attr('class', 'flight-time-text') |
|
.text(d => d.text) |
|
.attr('x', d => d.x) |
|
.attr('y', d => d.isTop ? -padding : dayHeight + padding) |
|
.attr('text-anchor', d => d.isLeft ? 'end' : 'start') |
|
.attr('dx', d => d.isLeft ? -3 : 3) |
|
.attr('dy', d => d.isTop ? '1em' : -3); |
|
} |
|
|
|
// Departure/arrival time markers |
|
dom.flight.selectAll('.flight-marker') |
|
.data([ |
|
['left', 'top', data.from.time], |
|
['left', 'bottom', convertTime(departUtc, 0, data.to.curOffset)], |
|
['right', 'top', convertTime(arriveUtc, 0, data.from.curOffset)], |
|
['right', 'bottom', data.to.time], |
|
]) |
|
.enter().call(createMarker); |
|
|
|
// Flight shape |
|
dom.flight.append('path') |
|
.attr('class', 'flight-duration') |
|
.style('fill', 'url(#flight-01)') |
|
.attr('d', [ |
|
'M', 0, 0, |
|
'L', flightWidth - rx, 0, |
|
'A', rx, ry, 0, 0, 1, flightWidth, ry, |
|
'L', flightWidth, dayHeight, |
|
rx, dayHeight, |
|
'A', rx, ry, 0, 0, 1, 0, dayHeight - ry, |
|
'Z' |
|
].join(' ')); |
|
|
|
// Flight text |
|
const flightText = dom.flight.append('text') |
|
.attr('class', 'flight-text') |
|
.text(data.flight) |
|
.attr('x', rx).attr('y', dayHeight / 2) |
|
.attr('dy', '0.35em'); |
|
flightText.append('tspan') |
|
.attr('class', 'flight-duration-text') |
|
.text(`(${minutesToTime(flightDuration).replace(':', 'h ')}m)`) |
|
.attr('dx', '0.5em'); |
|
}); |
|
</script> |