Skip to content

Instantly share code, notes, and snippets.

@almccon
Last active July 24, 2023 11:34
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save almccon/5ed5dee15c320b13165ff714126c8a91 to your computer and use it in GitHub Desktop.
Save almccon/5ed5dee15c320b13165ff714126c8a91 to your computer and use it in GitHub Desktop.
Mapbox routing demo (modified)
license: mit
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Fooder</title>
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
<script src='https://npmcdn.com/@turf/turf@4.0.0/turf.min.js'></script>
<script src="https://d3js.org/d3.v4.min.js"></script>
<link href='https://www.mapbox.com/base/latest/base.css' rel='stylesheet' />
<script src='https://unpkg.com/cheap-ruler@2.5.0/cheap-ruler.js'></script>
<script src='https://api.tiles.mapbox.com/mapbox-gl-js/v0.38.0/mapbox-gl.js'></script>
<link href='https://api.tiles.mapbox.com/mapbox-gl-js/v0.38.0/mapbox-gl.css' rel='stylesheet' />
<style>
body { margin:0; padding:0; }
#map {
position:absolute;
top:0;
bottom:0;
right:0px;
left:0px;
background:black;
}
.truck{
margin:-10px -10px;
}
.truck, .ping {
width:20px;
height:20px;
border:2px solid #fff;
border-radius:50%;
background:#3887be;
pointer-events: none;
}
.ping {
margin:-99999px;
border:0.025px solid #f9886c;
background:none;
transform: translateX(-50%) translateY(-50%);
}
.ping.active{
margin:0px;
z-index:99;
transform:translateX(-50%) translateY(-50%) scale(6,6);
opacity:0;
transition: all 0.75s ease-out;
transition-property: transform, opacity;
}
canvas:active {
cursor:-webkit-grabbing;
}
.step{
padding:10px;
border-bottom:1px solid #ddd;
overflow:hidden;
max-height:200px;
text-align:left;
color:#666;
}
.step:first-child {
background:white;
color:black;
font-weight:bold;
}
.transient{
pointer-events: none;
margin-top:-30px;
}
#phone {
position: absolute;
top: 15px;
left: 15px;
width: 250px;
height: 460px;
background: #fff;
border-radius: 25px;
z-index: 99;
font-family: sans-serif;
box-shadow: 0px 0px 15px #aaa;
transition: all 0.2s;
}
#phone.hide{
transform: translate(-200%);
}
#screen{
width: 90%;
height: 80%;
margin: 15% auto;
border-radius:8px;
overflow:hidden;
border:1px solid #ccc;
position:relative;
}
#queue {
background:#eee;
height:35%;
text-align:center;
overflow-y: scroll;
}
#queue:empty{
padding:30px 10px;
}
#queue:empty:after{
content: 'Click and drag from a restaurant to add a new order for delivery';
opacity:0.5;
}
.foodicon{
height:20px;
float:right;
font-size:0.5em;
color:#3887be;
}
.foodicon img{
width:20px;
display:inline;
float:right;
margin-left:2px;
}
#nav {
width:100%;
height:65%;
transition:all 0.5s;
border-top-left-radius: 7px;
border-top-right-radius: 7px;
}
#screen .mapboxgl-control-container{
display:none;
}
#carmarker{
z-index:99;
position:absolute;
width:50px;
height:50px;
border-radius:50%;
background:#3887be;
left:50%;
top:50%;
margin:-25px -25px;
transform: rotateX(60deg);
color: white;
text-align: center;
font-size: 3em;
line-height: 1.2em;
border:2px solid white;
box-shadow: 0px 10px 25px #666;
}
/*Modal stuff */
.dragger img{
position:absolute;
}
#cursor{
width:20px;
height:20px;
margin:18px;
}
.drag {
width: 60px;
height: 60px;
padding:10px;
position: relative;
overflow:visible;
left:30%;
}
.dragger{
position:relative;
animation: mymove 1.5s;
animation-iteration-count: infinite;
margin-top:-40px;
}
@keyframes mymove {
0% {transform: translateX(30%);opacity:0;}
20%{transform: translateX(30%);opacity:1; }
60% {transform: translateX(70%);}
80% {opacity:1;}
100% {transform: translateX(70%); opacity:0;}
}
</style>
</head>
<body>
<div id='phone'>
<div id='screen'>
<div id='queue' class='pin-bottom'></div>
<div id='nav'>
<div id='carmarker'>▲</div>
</div>
</div>
</div>
<div id='map' class='contain'>
<div id='modal-content' class='animate modal modal-content active' style='background:rgba(0,0,0,0.75)'>
<div class='col5 modal-body fill-white contain pad4' style='margin-top:15%'>
<div class=' row3 clearfix'>
<div class='prose prose-big center'>
Click and drag from any restaurant to add a new order for delivery
</div>
<div style='border-radius:30px; border:2px solid #aaa; display:inline-block' class='drag space-top2' >
<img src='https://www.mapbox.com/bites/00359/icons/pngs/pizza.png' style='width:60px'>
</div>
<div class='dragger space-left2'>
<img src='https://www.mapbox.com/bites/00359/icons/bubble-pizza.svg'>
<img src = 'https://www.mapbox.com/bites/00359/icons/cursor.png' id='cursor'>
</div>
</div>
<div class=''>
<div class='button space-top4 col12' onclick='d3.select(".modal-content").classed("active", false)'>Start simulation</div>
</div>
</div>
</div>
</div>
<script>
var state = {
loaded:{},
speedFactor: 50,
pause: true,
lastQueryTime: 0,
truckLocation:[-118.4153,34.0572],
lastAtRestaurant:{
taco: 0,
pizza: 0,
noodles: 0
},
keepTrack:[],
currentSchedule: [],
currentRoute: null,
pointHopper: {},
dragLine: [],
ruler: cheapRuler(41.8949, 'meters'),
//update map line geometry
updateRoute: function(wholeRoute){
var payload;
//state.updateNext();
if (!wholeRoute) payload = nothing;
else{
var currentChunk = turf.lineSlice(
turf.point(state.truckLocation),
state.currentSchedule[0],
wholeRoute.geometry
)
var restOfLine = turf.lineSlice(
state.currentSchedule[0],
state.currentSchedule[state.currentSchedule.length-1],
wholeRoute.geometry
);
restOfLine.properties.current = 'false';
currentChunk.properties.current = 'true';
payload = turf.featureCollection([restOfLine,currentChunk])
}
[map, driver]
.forEach(function(mapObject){
mapObject
.getSource('route')
.setData(payload)
})
},
//update queue display on phone
updateQueue: function(){
var queue = state.currentSchedule
.map(function(item){
return [item.properties.key, item.properties.type]
})
d3.selectAll('.step')
.remove();
d3.select('#queue')
.selectAll('.step')
.data(queue, function(d){return d})
.enter()
.append('div')
.classed('step', true)
.text(function(d){
if (d[1]) return 'Deliver '+ d[1]
else return 'Pick up '+ d[0] + ' order'
})
.append('div')
.classed('foodicon', true)
.text(function(d){
var arrow = d[1] ? '▼' : '▲'
return arrow
})
.append('img')
.attr('src', function(d){
var type = d[1] ? d[1] : d[0]
return 'https://www.mapbox.com/bites/00359/icons/pngs/'+type+'.png'
})
},
updatePickups: function(geojson){
[map, driver]
.forEach(function(mapObject){
mapObject
.getSource('pickups-symbol')
.setData(geojson)
})
},
// update next marker
updateNext: function(){
var nextStop = state.currentSchedule[0] ? state.currentSchedule[0] : nothing;
if (nextStop.properties && nextStop.properties.restaurant){
console.log('rest')
restaurants.features.forEach(function(ft){
ft.properties.next = ft.properties.type === nextStop.properties.key
console.log(ft.properties.next)
})
map.getSource('restaurants')
.setData(restaurants)
}
},
reset: function(){
console.log('resetting')
state.pause = true;
state.currentSchedule = state.keepTrack = [];
state.pointHopper = {};
rests.forEach(function(rest){
state.pointHopper[rest.type] = turf.point(rest.coordinates, {key:rest.type})
})
//update the queue and route
state.updateQueue();
if (state.loaded.map && state.loaded.driver){
map.getSource('next')
.setData(nothing);
state.updatePickups(nothing);
state.updateRoute();
}
},
onMousedown: function(e){
if (state.currentSchedule.length>=11) return
var ft = map.queryRenderedFeatures(e.point, {layers:['restaurants']})[0]
if (!ft) return;
//hide phone
d3.select('#phone')
.classed('hide', true)
// add transient marker to cursor
var marker = document.createElement('div');
marker.innerHTML = '<img src="https://www.mapbox.com/bites/00359/icons/bubble-'+ft.properties.type+'.svg">'
marker.classList = 'transient';
state.transientMarker = new mapboxgl.Marker(marker)
.setLngLat(ft.geometry.coordinates)
.addTo(map);
//set drag guides
map.getSource('dragHighlight')
.setData(ft)
state.dragLine[0] = ft.geometry.coordinates;
map.setClasses(['dragging']);
state.pause = true;
state.updateRoute();
d3.select('.ping')
.classed('active', false);
map.once('mouseup', function(e){
state.onMouseup(e, ft)
})
},
onMouseup: function(e, ft){
d3.select('#phone')
.classed('hide', false);
map.setClasses([])
.getSource('dragLine')
.setData(nothing)
d3.select('canvas')
.style('cursor', null);
state.newPickup(map.unproject(e.point), ft.properties.type);
state.transientMarker.remove();
}
}
mapboxgl.accessToken = 'pk.eyJ1IjoicGV0ZXJxbGl1IiwiYSI6ImpvZmV0UEEifQ._D4bRmVcGfJvo1wjuOpA1g';
var embed = window.location.href.indexOf('embed=true') !==-1;
var map = new mapboxgl.Map({
hash: false,
scrollZoom: !embed,
container: 'map', // container id
style: 'mapbox://styles/peterqliu/cj5k2tj0d16ul2rmlwyw00gth', //stylesheet location
center: state.truckLocation, // starting position
zoom: 13 // starting zoom
});
if(embed) map.addControl(new mapboxgl.NavigationControl());
var driver = new mapboxgl.Map({
container: 'nav', // container id
style: 'mapbox://styles/peterqliu/cj5k3b63b176l2snvd7n6by90', //stylesheet location
center: state.truckLocation,
pitch:60,
interactive: false,
zoom: 17 // starting zoom
});
var rests = [
{'type': 'taco',
'icon': '',
'coordinates': [-118.4183,34.0572]
},
{'type': 'pizza',
'icon': '',
'coordinates': [-118.4183,34.0542]
},
{'type': 'noodles',
'icon': '',
'coordinates': [-118.4153,34.0542]
},
]
var bbox = [
[-118.4053,34.0072],
[-118.4253,34.1072]
]
state.reset();
var nothing = turf.featureCollection([]);
var restaurants = turf.featureCollection(rests.map(function(rest){
return turf.point(rest.coordinates, rest)
}))
var pickups = turf.featureCollection([])
driver.on('load', function(){
state.loaded.driver = true;
this.addSource('route',{
'data': nothing,
'type': 'geojson'
})
driver.addLayer({
'id': '3d-buildings',
'source': 'composite',
'source-layer': 'building',
'filter': ['==', 'extrude', 'true'],
'type': 'fill-extrusion',
'paint': {
'fill-extrusion-color': 'hsl(35, 28%, 70%)',
'fill-extrusion-height': {
'type': 'identity',
'property': 'height'
},
'fill-extrusion-base': {
'type': 'identity',
'property': 'min_height'
},
'fill-extrusion-opacity': .6
}
}, 'waterway-label')
.addLayer({
'id':'routeline',
'type':'line',
'source':'route',
'layout':{
'line-join':'round',
'line-cap':'round'
},
'paint':{
'line-color': '#3887be',
'line-width': {
base:1,
stops:[[12,12],[22,16]]
}
}
}, 'waterway-label')
.addLayer({
'id':'restaurants',
'type':'circle',
'source':{
'data': restaurants,
'type': 'geojson'
},
'paint':{
'circle-radius':{
'property': 'next',
'type': 'categorical',
'default': 25,
'stops':[
[true, 27],
[false, 25],
]
},
'circle-color': 'white',
'circle-stroke-color': {
'property': 'next',
'type': 'categorical',
'default': '#ccc',
'stops':[
[true, '#3887be'],
[false, '#ccc'],
]
},
'circle-stroke-width': {
'property': 'next',
'type': 'categorical',
'default': 2,
'stops':[
[true, 3],
[false, 2],
]
}
},
'paint.dragging':{
'circle-stroke-width': 0
}
})
.addLayer({
'id':'restaurants-symbol',
'type':'symbol',
'source':{
'data': restaurants,
'type': 'geojson'
},
'layout':{
'icon-image':'{type}',
'icon-size':0.125
},
'paint':{
'text-color': '#3887be'
}
})
.addLayer({
'id':'pickups-symbol',
//'filter':['has', 'orderTime'],
'type':'symbol',
'source':{
'data': nothing,
'type': 'geojson'
},
'layout':{
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-image':'bubble-{type}',
//'text-field': '{index}'
},
'paint':{
'text-translate':[-10,-30],
'icon-translate':[12,-15],
'text-color': '#3887be'
}
})
})
map.on('load', function(){
map.fitBounds(bbox)
state.loaded.map = true;
this.addSource('route',{
'data': nothing,
'type': 'geojson'
})
.addSource('next',{
'data': nothing,
'type': 'geojson'
})
this
.addLayer({
'id':'routearrows',
'type':'symbol',
'source':'route',
'filter':['==', 'current', 'true'],
'layout':{
'symbol-placement': 'line',
'text-field': '▶',
'text-size':{
base:1,
stops:[[12,24],[22,60]]
},
'symbol-spacing': {
base:1,
stops:[[12,30],[22,160]]
},
'text-keep-upright': false
},
'paint':{
'text-color': '#3887be',
'text-halo-color':'hsl(55, 11%, 96%)',
'text-halo-width':3
}
}, 'waterway-label')
.addLayer({
'id': 'blackout',
'type':'background',
'paint':{
'background-color': '#fff',
'background-opacity':0
},
'paint.dragging':{
'background-opacity':0.75
}
})
.addLayer({
'id':'dragLine',
'type':'line',
'source':{
'data': nothing,
'type': 'geojson'
},
'layout':{
'line-cap':'round'
},
'paint':{
'line-width':0,
'line-width-transition':{
'duration':0
}
},
'paint.dragging':{
'line-color':'#f9886c',
'line-opacity':1,
'line-width': 2,
'line-dasharray':[0,4],
}
})
// .addLayer({
// 'id':'dragArrowhead',
// 'type':'symbol',
// 'source':{
// 'data': nothing,
// 'type': 'geojson'
// },
// 'layout':{
// 'text-cap':'round'
// },
// 'paint':{
// 'text-opacity':0
// },
// 'paint.dragging':{
// 'text-color':'#f9886c',
// 'text-opacity':1,
// 'text-field': '>',
// }
// })
.addLayer({
'id':'routeline-inactive',
'type':'line',
'source':'route',
'filter':['==', 'current', 'false'],
'layout':{
'line-cap':'round'
},
'paint':{
'line-color':'#3887be',
'line-opacity':0.8,
'line-width': {
base:1,
stops:[[12,3],[22,12]]
},
'line-dasharray':[0,2],
}
}, 'waterway-label')
.addLayer({
'id':'routeline-active',
'type':'line',
'source':'route',
'filter':['==', 'current', 'true'],
'layout':{
'line-join':'round',
'line-cap':'round'
},
'paint':{
'line-color':{
'property': 'current',
'type': 'categorical',
'stops':[
['true', '#3887be'],
['false', '#aaa']
]
},
'line-width': {
base:1,
stops:[[12,3],[22,12]]
}
}
}, 'waterway-label')
.addLayer({
'id':'restaurants',
'type':'circle',
'source':{
'data': restaurants,
'type': 'geojson'
},
'paint':{
'circle-radius':{
'property': 'next',
'type': 'categorical',
'default': 25,
'stops':[
[true, 27],
[false, 25],
]
},
'circle-color': 'white',
'circle-stroke-color': {
'property': 'next',
'type': 'categorical',
'default': '#ccc',
'stops':[
[true, '#3887be'],
[false, '#ccc'],
]
},
'circle-stroke-width': {
'property': 'next',
'type': 'categorical',
'default': 2,
'stops':[
[true, 3],
[false, 2],
]
}
},
'paint.dragging':{
'circle-stroke-width': 0
}
})
.addLayer({
'id':'dragHighlight',
'type':'circle',
'source':{
'data': nothing,
'type': 'geojson'
},
'paint':{
'circle-radius':25,
'circle-opacity':0
},
'paint.dragging':{
'circle-stroke-width':3,
'circle-stroke-color': '#f9886c'
}
})
.addLayer({
'id':'restaurants-symbol',
'type':'symbol',
'source':{
'data': restaurants,
'type': 'geojson'
},
'layout':{
'icon-image':'{type}',
'text-font':['icomoon Regular'],
'icon-size':0.125
},
'paint':{
'text-color': '#3887be'
}
})
.addLayer({
'id':'pickups-symbol',
//'filter':['has', 'orderTime'],
'type':'symbol',
'source':{
'data': nothing,
'type': 'geojson'
},
'layout':{
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'icon-image':'bubble-{type}',
//'text-field': '{index}'
},
'paint':{
'text-translate':[-10,-30],
'icon-translate':[12,-15],
'text-color': '#3887be'
}
})
var marker = document.createElement('div');
marker.classList = 'truck';
state.truckMarker = new mapboxgl.Marker(marker)
.setLngLat(state.truckLocation)
.addTo(map);
state.pingMarker = document.createElement('div');
state.pingMarker.innerHTML = '<div class="ping"></div>';
state.pingMarker = new mapboxgl.Marker(state.pingMarker)
.setLngLat(state.truckLocation)
.addTo(map);
map
.on('mousemove', function(e){
var ft = map.queryRenderedFeatures(e.point, {layers:['restaurants']})[0]
if (ft) map.dragPan.disable();
else map.dragPan.enable()
if (e.originalEvent.which === 1){
var pt = [map.unproject(e.point).lng, map.unproject(e.point).lat];
state.transientMarker
.setLngLat(pt);
state.dragLine[1] = pt;
map.getSource('dragLine')
.setData(turf.lineString(state.dragLine))
}
})
.on('mousedown', function(e){
state.onMousedown(e)
})
})
function randomPoint(){
var pt = turf.random('points', 1,{bbox:bbox}).features[0].geometry.coordinates
var randomNumber = parseFloat((Math.random()*100).toFixed(0))%3;
var randomType = rests[randomNumber].type;
state.newPickup({lng:pt[0], lat:pt[1]}, randomType)
};
state.newPickup = function (coords, type){
//state.pingMarker._element.classList ='ping mapboxgl-marker';
d3.select('.ping')
.classed('active', false)
var pt = addPoint(coords, type);
state.pointHopper[pt.properties.key] = pt;
pickups.features.push(pt);
state.pingMarker
.setLngLat(pt.geometry.coordinates)
d3.select('.ping')
.classed('active', true)
console.log(assembleQueryURL());
d3.json(assembleQueryURL(), function(err, resp){
state.currentRoute = resp.trips[0];
state.pause = false;
// draw the line except the last step (no need for round trip)
var lastStep = resp.trips[0].legs[resp.trips[0].legs.length-1].steps[0].intersections[0].location
var slicedLine = turf.lineSlice(
turf.point(state.currentRoute.geometry.coordinates[0]),
lastStep,
state.currentRoute.geometry
)
state.currentRoute.geometry = slicedLine;
state.lastQueryTime = Date.now();
var timeSoFar = Date.now();
state.currentSchedule = [];
for(var i =1; i<resp.waypoints.length; i++){
var legTime = resp.trips[0].legs[i].duration;
timeSoFar += legTime*1000/state.speedFactor;
// snap pickups to the road grid, and edit entries accordingly
state.keepTrack[i].geometry.coordinates = resp.waypoints[i].location;
var waypointIndex = resp.waypoints[i]['waypoint_index'];
state.currentSchedule[waypointIndex] = state.keepTrack[i];
}
state.currentSchedule.shift();
state.updatePickups(
turf.featureCollection(
state.currentSchedule.map(
function(ft, index){
ft.properties.index = index;
return ft
}
)
)
)
var nextDestination = state.pointHopper[state.currentSchedule[0].properties.key];
nextDestination.properties.restaurant = !nextDestination.properties.orderTime
tick();
state.updateQueue();
state.updateRoute(slicedLine);
})
}
function addPoint(coords, type){
var point = turf.point(
[coords.lng, coords.lat],
{
type: type,
orderTime: Date.now(),
key: Math.random()
}
)
return point
}
function tick(){
if (!state.pause){
moveTruck()
requestAnimationFrame(tick)
}
}
function moveTruck(){
// update truck position
var timeElapsed = (Date.now()-state.lastQueryTime)/(1000/state.speedFactor);
var timeRatio = timeElapsed/state.currentRoute.duration
var progressDistance = state.currentRoute.distance * timeRatio;
var newSpot = state.ruler.along(state.currentRoute.geometry.geometry.coordinates, progressDistance);
var bearing = state.ruler.bearing(state.truckLocation, newSpot)
state.truckLocation = newSpot;
state.truckMarker
.setLngLat(state.truckLocation);
driver.setCenter(state.truckLocation)
.easeTo({bearing:bearing, duration:200})
//check if truck has reached the next waypoint
var currentStep = state.currentSchedule[0];
if(currentStep && state.ruler.distance(state.truckLocation, currentStep.geometry.coordinates)<10){
var key = currentStep.properties.key;
state.currentSchedule.shift();
//update route line
var slicedLine = turf.lineSliceAlong(
state.currentRoute.geometry,
progressDistance/1000,
Infinity,
'kilometers'
)
pickups = turf.featureCollection(
state.currentSchedule.filter(function(spot){
return spot.properties.key !== key
})
)
// if this location is a dropoff, remove it from the hopper and update map markers
if (typeof key === 'number') {
delete state.pointHopper[currentStep.properties.key];
pickups = turf.featureCollection(
objectToArray(state.pointHopper)
.filter(function(pt){
return typeof pt.properties.key === 'number'
})
)
state.updatePickups(pickups)
}
//if it's a restaurant, update the arrival time
else state.lastAtRestaurant[key] = Date.now();
//if no more deliveries, pause;
if (state.currentSchedule.length === 0) {
state.pause = true;
map.getSource('next')
.setData(nothing)
state.updateRoute()
}
else {
state.updateRoute(slicedLine);
var nextDestination = state.pointHopper[state.currentSchedule[0].properties.key]
nextDestination.properties.restaurant = !nextDestination.properties.orderTime
map.getSource('next')
.setData(nextDestination);
}
state.updateQueue()
}
}
function assembleQueryURL(){
var coordinates = [state.truckLocation];
var distributions = [];
state.keepTrack = [state.truckLocation];
rests.forEach(function(rest, index){
var restJobs = objectToArray(state.pointHopper)
.filter(function(job){
return job.properties.type === rest.type;
})
// if there are actually orders from this restaurant
if (restJobs.length > 0){
var needToPickUp = restJobs.filter(function(d,i){
return d.properties.orderTime > state.lastAtRestaurant[rest.type]
}).length>0;
if (needToPickUp) {
var restaurantIndex = coordinates.length;
coordinates.push(rest.coordinates);
// push the restaurant itself into the array
state.keepTrack.push(state.pointHopper[rest.type]);
}
restJobs.forEach(function(d,i){
//add pickup to list
state.keepTrack.push(d);
coordinates.push(d.geometry.coordinates);
// if order not yet picked up, add a reroute
if (needToPickUp && d.properties.orderTime>state.lastAtRestaurant[rest.type]){
distributions.push(restaurantIndex+','+(coordinates.length-1))
}
})
}
});
return ('https://api.mapbox.com/optimized-trips/v1/mapbox/driving/'+coordinates.join(';')+'?distributions='+distributions.join(';')+'&overview=full&steps=true&geometries=geojson&source=first&access_token='+mapboxgl.accessToken)
}
function objectToArray(obj){
var keys = Object.keys(obj);
var payload = keys.map(function(key){
return obj[key]
})
return payload
}
window.addEventListener('blur', function(){state.reset()})
</script>
<img src='https://www.mapbox.com/bites/00359/icons/bubble-noodles.svg'>
<img src='https://www.mapbox.com/bites/00359/icons/bubble-taco.svg'>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment