Skip to content

Instantly share code, notes, and snippets.

@gilmoreorless
Last active April 19, 2019 22:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save gilmoreorless/7f0975efab20d668118ae0b654cac695 to your computer and use it in GitHub Desktop.
Save gilmoreorless/7f0975efab20d668118ae0b654cac695 to your computer and use it in GitHub Desktop.
Flight time zone indicators
license: mit

International flights (or domestic flights within large countries) can cross many time zones. The combination of departure time + time zone, arrival time + time zone, and flight duration can often get confusing.

This is an idea I had to show all the relative times involved, to save mental calculations and headaches. I’d love to see an interface like this in travel-planning applications and websites (especially while trying to book flights).

Some notes about the data:

  • This is hard-coded to a specific flight (Etihad Airways 455 from Sydney to Abu Dhabi) on a specific day (January 18, 2018), to get the idea across. On other days, the departure/arrival times could be affected by daylight saving changes.
  • Each day shows day/night/twilight colouring — these are accurate for the locations on these days. Sunrise/sunset/twilight data comes from https://sunrise-sunset.org/ — this is why Sydney’s period of daylight is shown as being longer than Abu Dhabi’s.

Why choose this flight in particular to display? Primarily because it’s a long flight (nearly 15 hours) and crosses many time zones. Another reason is that it’s a flight I’ve caught before, while travelling to a conference to present a talk about time zones, so it seemed an appropriate example.

{
"_comment_": "Data hard-coded for 2018-01-18 as this is only a prototype.",
"_copyright_": "Sunrise/sunset data from https://sunrise-sunset.org/api",
"flight": "EY455",
"from": {
"name": "Sydney",
"code": "SYD",
"time": "21:50",
"tz": "Australia/Sydney",
"curOffset": 660,
"sun": {
"sunrise": "2018-01-17T19:03:00+00:00",
"sunset": "2018-01-18T09:08:12+00:00",
"solar_noon": "2018-01-18T02:05:36+00:00",
"day_length": 50712,
"civil_twilight_begin": "2018-01-17T18:35:02+00:00",
"civil_twilight_end": "2018-01-18T09:36:10+00:00",
"nautical_twilight_begin": "2018-01-17T18:00:50+00:00",
"nautical_twilight_end": "2018-01-18T10:10:21+00:00",
"astronomical_twilight_begin": "2018-01-17T17:24:05+00:00",
"astronomical_twilight_end": "2018-01-18T10:47:07+00:00"
}
},
"to": {
"name": "Abu Dhabi",
"code": "AUH",
"time": "05:40",
"tz": "Asia/Dubai",
"curOffset": 240,
"sun": {
"sunrise": "2018-01-18T03:07:59+00:00",
"sunset": "2018-01-18T13:58:05+00:00",
"solar_noon": "2018-01-18T08:33:02+00:00",
"day_length": 39006,
"civil_twilight_begin": "2018-01-18T02:43:47+00:00",
"civil_twilight_end": "2018-01-18T14:22:18+00:00",
"nautical_twilight_begin": "2018-01-18T02:15:59+00:00",
"nautical_twilight_end": "2018-01-18T14:50:06+00:00",
"astronomical_twilight_begin": "2018-01-18T01:48:33+00:00",
"astronomical_twilight_end": "2018-01-18T15:17:31+00:00"
}
}
}
<!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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment