DailyUI #020 - Location tracker.
Trying out Labella.js for the timeline, combined with Leaflet for the map.
A Pen by weedkiller on CodePen.
.container | |
.map | |
.map__inner#map | |
.sidebar | |
h1 Location History | |
.timeline#timeline |
DailyUI #020 - Location tracker.
Trying out Labella.js for the timeline, combined with Leaflet for the map.
A Pen by weedkiller on CodePen.
function Location(title, locName, date, lat, lng) { | |
this.title = title; | |
this.locName = locName; | |
this.date = date; | |
this.lat = lat; | |
this.lng = lng; | |
} | |
var data = [ | |
new Location('Order processed', 'Phoenix, AZ, USA', new Date(2015, 3, 10), 33.43, -112.15), | |
new Location('Picked up by courier', 'Phoenix, AZ, USA', new Date(2015, 3, 11), 33.43, -112.15), | |
new Location('Departed depot', 'Phoenix, AZ, USA', new Date(2015, 3, 12), 33.67, -112.11), | |
new Location('Cleared customs', 'LAX, CA, USA', new Date(2015, 3, 15), 34.05, -117.65), | |
new Location('Departed country', 'LAX, CA, USA', new Date(2015, 3, 15), 34.05, -117.65), | |
new Location('Arrived in country', 'Auckland, NZ', new Date(2015, 3, 17), -37.00, 174.78), | |
new Location('Cleared customs', 'Auckland, NZ', new Date(2015, 3, 18), -37.00, 174.78), | |
new Location('Picked up by courier', 'Auckland, NZ', new Date(2015, 3, 18), -37.00, 174.78), | |
new Location('ETA', 'Hamilton, NZ', new Date(2015, 3, 20), -37.80, 175.28) | |
]; | |
var colTop = "#27a4e5", | |
colBottom = "#27cfe5"; | |
var map = L.map('map', {zoomControl: false, worldCopyJump: true}).setView([data[data.length - 1].lat, data[data.length - 1].lng], 4); | |
L.tileLayer('http://stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.{ext}', { | |
attribution: 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> — Map data © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', | |
subdomains: 'abcd', | |
minZoom: 0, | |
maxZoom: 20, | |
ext: 'png' | |
}).addTo(map); | |
var markerIcon = L.divIcon({className: 'map__marker'}); | |
// Add a marker and popup for each data point | |
data.forEach(function(d) { | |
var marker = L.marker([d.lat, d.lng], { icon: markerIcon }).addTo(map); | |
marker.bindPopup(d.title + '<br>' + d.locName); | |
d.marker = marker; | |
}); | |
// Timeline creation | |
var margin = {top: 10, right: 0, bottom: 50, left: 70}, | |
width = 272 - margin.left - margin.right, | |
height = (data.length * 80) - margin.top - margin.bottom; | |
var vis = d3.select('#timeline') | |
.append('svg') | |
.attr('width', width + margin.left + margin.right) | |
.attr('height', height + margin.top + margin.bottom) | |
.append('g') | |
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); | |
var labelsGroup = vis.append('g') | |
.classed('timeline__labels', true); | |
var linksGroup = vis.append('g') | |
.classed('timeline__lines', true); | |
var timeScale = d3.time.scale() | |
.domain([data[0].date, data[data.length - 1].date]) | |
.range([0, height]).nice(d3.time.day); | |
var colorScale = d3.time.scale() | |
.domain([0, height]) | |
.range([colTop, colBottom]); | |
var axis = d3.svg.axis() | |
.scale(timeScale) | |
.orient('left') | |
.tickValues(data.map(function(d) { return d.date })) | |
.tickSize(0, 0) | |
.tickPadding(20); | |
var lineGradient = vis.append('linearGradient') | |
.attr('id', 'lineGradient') | |
.attr('x1', 0).attr('x2', margin.top) | |
.attr('y1', 0).attr('y2', height) | |
.attr("gradientUnits", "userSpaceOnUse"); | |
lineGradient.append("stop") | |
.attr("offset", "0") | |
.attr("stop-color", colTop); | |
lineGradient.append("stop") | |
.attr("offset", "1") | |
.attr("stop-color", colBottom); | |
var axisEl = vis.append('g') | |
.classed('timeline__line', true) | |
.call(axis); | |
// Add a circle to each date on the timeline | |
axisEl.selectAll('.tick') | |
.append('circle') | |
.classed('timeline__point', true) | |
.attr('r', 6) | |
.attr('stroke', function(d) { return colorScale(timeScale(d)) }); | |
// Moving the timeline line behind the points | |
var line = axisEl.select('.domain').remove(); | |
axisEl.node().insertBefore(line.node(), axisEl.node().children[0]); | |
var labelPadding = 10; // Top/bottom padding around each label | |
var placeholder = labelsGroup.append('text'); | |
// Getting the height of each label by adding each to the DOM then grabbing its bounds | |
var nodes = data.map(function(d) { | |
var bounds = placeholder.text(d.title)[0][0].getBBox(); | |
return new labella.Node(timeScale(d.date), bounds.height + (labelPadding * 2), d); | |
}); | |
placeholder.remove(); | |
var renderer = new labella.Renderer({ | |
layerGap: 20, | |
nodeHeight: 150, | |
direction: 'right' | |
}); | |
function drawLabels(nodes) { | |
renderer.layout(nodes); | |
var labels = labelsGroup.selectAll('.timeline__label') | |
.data(nodes) | |
.enter() | |
.append('g') | |
.classed('timeline__label', true) | |
.attr('transform', function(d){ return 'translate(' + d.x + ',' + (d.y - d.dy/2) + ')'; }); | |
labels.append('text') | |
.text(function(d) { return d.data.title }) | |
.attr('dominant-baseline', 'central') | |
.attr('y', labelPadding) | |
.attr('dy', '0.55em'); | |
labels.on('click', function(d) { | |
map.panTo(d.data.marker.getLatLng(), {animate: true}) | |
d.data.marker.openPopup(); | |
}); | |
var links = linksGroup.selectAll('.timeline__links') | |
.data(nodes) | |
.enter() | |
.append('path') | |
.classed('timeline__link', true) | |
.attr('d', function(d){ return renderer.generatePath(d); }); | |
linksGroup.attr('transform', 'translate(15, 0)'); | |
labelsGroup.attr('transform', 'translate(20, 0)'); | |
} | |
var force = new labella.Force() | |
.nodes(nodes) | |
.on('end', function() { | |
drawLabels(force.nodes()); | |
}).start(100); |
<script src="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js"></script> | |
<script src="https://cdn.rawgit.com/twitter/labella.js/bdfb26ef4e6f34cf6cd73f7016f8efdc7ffde6e4/dist/labella.min.js"></script> |
$col-bg: #0d264e; | |
$col-line: #27b4e5; | |
$font-stack: Roboto, Helvetica, Arial, sans-serif; | |
$break-medium-min: 560px; | |
html, | |
body { | |
height: 100%; | |
} | |
body { | |
font-family: $font-stack; | |
font-size: 16px; | |
font-weight: 400; | |
} | |
.container { | |
display: flex; | |
flex-direction: column; | |
height: 100%; | |
@media (min-width: $break-medium-min) { | |
flex-direction: row; | |
} | |
} | |
.sidebar { | |
box-sizing: border-box; | |
width: 100%; | |
flex-direction: row; | |
flex-shrink: 1; | |
flex-grow: 1; | |
padding: 24px; | |
color: white; | |
background: $col-bg; | |
box-shadow: 0 0 10px rgba(0,0,0,0.5); | |
z-index: 1; | |
overflow-y: auto; | |
@media (min-width: $break-medium-min) { | |
order: -1; | |
width: 320px; | |
flex-grow: 0; | |
flex-shrink: 0; | |
} | |
} | |
.map { | |
flex-shrink: 0; | |
width: 100%; | |
height: 200px; | |
background: black; // in case (somehow) the gradient doesn't work but blend mode does | |
background: radial-gradient(circle farthest-corner at 100% 10%, rgb(86, 172, 203), rgb(195, 190, 255)); | |
@media (min-width: $break-medium-min) { | |
flex-grow: 1; | |
flex-shrink: 1; | |
height: 100%; | |
} | |
&__inner { | |
width: 100%; | |
height: 100%; | |
mix-blend-mode: screen; | |
} | |
&__marker { | |
display: block; | |
width: 10px; | |
height: 10px; | |
left: -5px; | |
border-radius: 50%; | |
border: 4px solid #029DFF; | |
background: white; | |
box-shadow: 0 2px 2px 0px black; | |
} | |
.leaflet-container .leaflet-control-attribution { | |
color: black; | |
background: white; | |
} | |
.leaflet-popup-content-wrapper { | |
font-family: $font-stack; | |
font-size: 16px; | |
color: black; | |
border-radius: 2px; | |
box-shadow: 0 2px 8px black; | |
} | |
.leaflet-popup-content-wrapper, | |
.leaflet-popup-tip { | |
background: white; | |
} | |
} | |
h1 { | |
font-weight: 300; | |
font-size: 1.8em; | |
margin-bottom: 1em; | |
} | |
.timeline { | |
width: 100%; | |
svg { | |
display: block; | |
margin: 0 auto; | |
} | |
&__line { | |
.domain { | |
stroke: url('#lineGradient'); | |
stroke-width: 4px; | |
fill: none; | |
shape-rendering: crispEdges; | |
} | |
text { | |
text-transform: uppercase; | |
font-size: 0.8em; | |
font-weight: 300; | |
fill: white; | |
opacity: 0.6; | |
} | |
} | |
&__point { | |
fill: $col-bg; | |
stroke-width: 4px; | |
} | |
&__labels { | |
text { | |
font-size: 1em; | |
fill: white; | |
cursor: pointer; | |
} | |
} | |
&__link { | |
fill: none; | |
stroke: white; | |
stroke-width: 1px; | |
stroke-dasharray: 2,5; | |
} | |
} |
<link href="https://fonts.googleapis.com/css?family=Roboto:400,300,700,400italic" rel="stylesheet" /> | |
<link href="http://cdn.leafletjs.com/leaflet/v0.7.7/leaflet.css" rel="stylesheet" /> |