Skip to content

Instantly share code, notes, and snippets.

@w8r
Last active August 6, 2019 23:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save w8r/26b4f1a6ff0785a71c290d798337689a to your computer and use it in GitHub Desktop.
Save w8r/26b4f1a6ff0785a71c290d798337689a to your computer and use it in GitHub Desktop.
Leaflet + mapbox-gl
license: mit
height: 500
border: no
node_modules

Leaflet-mapbox-gl with padding

This gist solves the problem with map flickering of mapbox/mapbox-gl-leaflet by adding a padding around the mapbox-gl overlay.

As you can see in this example, panning or zooming the map can produce very unpleasant flickering on around the canvas edges, something that is avoided in leaflet by using buffer tiles: Screenshot 2019-08-07 00 59 05

This PR addresses that issue by introducing a relative padding around the overlay containing the mapbox canvas. The arbitrary value of the padding is 0.15, somewhat close to the value used in the leaflet vector renderers for the same purpose.

You can see the result comparison here https://bl.ocks.org/w8r/26b4f1a6ff0785a71c290d798337689a (map on the left has the padding, map on the right doesn't)

It also bumps the libraries in the examples (there was a maximum call stack exception in the basic example, rooted somewhere in the transformation update in mapbox-gl-js@0.35.x)

I also took the liberty to remove the throttle function implementation, as it has been the part of leaflet API since v1.0.0 for about 3 years now.

Made with blockup

*{box-sizing:border-box}body,html{margin:0;padding:0;width:100%;height:100%}#container{display:-ms-flexbox;display:flex;-ms-flex-direction:row;flex-direction:row;height:100%}#map1,#map2{width:50%;height:100%}
!function(t){function n(c){if(a[c])return a[c].exports;var e=a[c]={i:c,l:!1,exports:{}};return t[c].call(e.exports,e,e.exports,n),e.l=!0,e.exports}var a={};n.m=t,n.c=a,n.i=function(t){return t},n.d=function(t,a,c){n.o(t,a)||Object.defineProperty(t,a,{configurable:!1,enumerable:!0,get:c})},n.n=function(t){var a=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(a,"a",a),a},n.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},n.p="",n(n.s=0)}([function(module,exports,__webpack_require__){"use strict";eval("\n\nvar map1 = L.map(\"map1\", {\n center: [40, -74.50],\n zoom: 9\n});\n\nvar gl1 = L.mapboxGL({\n attribution: '<a href=\"https://www.maptiler.com/copyright/\" target=\"_blank\">© MapTiler</a> <a href=\"https://www.openstreetmap.org/copyright\" target=\"_blank\">© OpenStreetMap contributors</a>',\n accessToken: 'not-needed',\n style: 'https://api.maptiler.com/maps/basic/style.json?key=WrAno2oMdcq6tir0zRa8'\n}).addTo(map1);\n\n// mapboxgl.accessToken = 'pk.eyJ1IjoidzhyIiwiYSI6IlF2Nlh6QVkifQ.D7BkmeoMI7GEkMDtg3durw';\n// const glmap = new mapboxgl.Map({\n// container: 'map2', // container id\n// style: 'mapbox://styles/mapbox/streets-v10', // stylesheet location\n// center: [-74.50, 40], // starting position [lng, lat]\n// zoom: 8 // starting zoom\n// });\n\nvar map2 = L.map('map2', {\n center: [40, -74.50],\n zoom: 9\n});\n\nvar gl2 = L.mapboxGL({\n attribution: '<a href=\"https://www.maptiler.com/copyright/\" target=\"_blank\">© MapTiler</a> <a href=\"https://www.openstreetmap.org/copyright\" target=\"_blank\">© OpenStreetMap contributors</a>',\n accessToken: 'not-needed',\n style: 'https://api.maptiler.com/maps/basic/style.json?key=WrAno2oMdcq6tir0zRa8',\n padding: 0\n}).addTo(map2);//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMC5qcyIsInNvdXJjZXMiOlsid2VicGFjazovLy9zY3JpcHQuanM/OWE5NSJdLCJzb3VyY2VzQ29udGVudCI6WyJjb25zdCBtYXAxID0gTC5tYXAoXCJtYXAxXCIsIHtcbiAgY2VudGVyOiBbNDAsIC03NC41MF0sXG4gIHpvb206IDlcbn0pO1xuXG52YXIgZ2wxID0gTC5tYXBib3hHTCh7XG4gIGF0dHJpYnV0aW9uOiAnPGEgaHJlZj1cImh0dHBzOi8vd3d3Lm1hcHRpbGVyLmNvbS9jb3B5cmlnaHQvXCIgdGFyZ2V0PVwiX2JsYW5rXCI+wqkgTWFwVGlsZXI8L2E+IDxhIGhyZWY9XCJodHRwczovL3d3dy5vcGVuc3RyZWV0bWFwLm9yZy9jb3B5cmlnaHRcIiB0YXJnZXQ9XCJfYmxhbmtcIj7CqSBPcGVuU3RyZWV0TWFwIGNvbnRyaWJ1dG9yczwvYT4nLFxuICBhY2Nlc3NUb2tlbjogJ25vdC1uZWVkZWQnLFxuICBzdHlsZTogJ2h0dHBzOi8vYXBpLm1hcHRpbGVyLmNvbS9tYXBzL2Jhc2ljL3N0eWxlLmpzb24/a2V5PVdyQW5vMm9NZGNxNnRpcjB6UmE4J1xufSkuYWRkVG8obWFwMSk7XG5cbi8vIG1hcGJveGdsLmFjY2Vzc1Rva2VuID0gJ3BrLmV5SjFJam9pZHpoeUlpd2lZU0k2SWxGMk5saDZRVmtpZlEuRDdCa21lb01JN0dFa01EdGczZHVydyc7XG4vLyBjb25zdCBnbG1hcCA9IG5ldyBtYXBib3hnbC5NYXAoe1xuLy8gICBjb250YWluZXI6ICdtYXAyJywgLy8gY29udGFpbmVyIGlkXG4vLyAgIHN0eWxlOiAnbWFwYm94Oi8vc3R5bGVzL21hcGJveC9zdHJlZXRzLXYxMCcsIC8vIHN0eWxlc2hlZXQgbG9jYXRpb25cbi8vICAgY2VudGVyOiBbLTc0LjUwLCA0MF0sIC8vIHN0YXJ0aW5nIHBvc2l0aW9uIFtsbmcsIGxhdF1cbi8vICAgem9vbTogOCAvLyBzdGFydGluZyB6b29tXG4vLyB9KTtcblxuY29uc3QgbWFwMiA9IEwubWFwKCdtYXAyJywge1xuICBjZW50ZXI6IFs0MCwgLTc0LjUwXSxcbiAgem9vbTogOVxufSk7XG5cbnZhciBnbDIgPSBMLm1hcGJveEdMKHtcbiAgYXR0cmlidXRpb246ICc8YSBocmVmPVwiaHR0cHM6Ly93d3cubWFwdGlsZXIuY29tL2NvcHlyaWdodC9cIiB0YXJnZXQ9XCJfYmxhbmtcIj7CqSBNYXBUaWxlcjwvYT4gPGEgaHJlZj1cImh0dHBzOi8vd3d3Lm9wZW5zdHJlZXRtYXAub3JnL2NvcHlyaWdodFwiIHRhcmdldD1cIl9ibGFua1wiPsKpIE9wZW5TdHJlZXRNYXAgY29udHJpYnV0b3JzPC9hPicsXG4gIGFjY2Vzc1Rva2VuOiAnbm90LW5lZWRlZCcsXG4gIHN0eWxlOiAnaHR0cHM6Ly9hcGkubWFwdGlsZXIuY29tL21hcHMvYmFzaWMvc3R5bGUuanNvbj9rZXk9V3JBbm8yb01kY3E2dGlyMHpSYTgnLFxuICBwYWRkaW5nOiAwXG59KS5hZGRUbyhtYXAyKTtcblxuXG5cbi8vIFdFQlBBQ0sgRk9PVEVSIC8vXG4vLyBzY3JpcHQuanMiXSwibWFwcGluZ3MiOiI7O0FBQUE7QUFDQTtBQUNBO0FBRkE7QUFDQTtBQUlBO0FBQ0E7QUFDQTtBQUNBO0FBSEE7QUFDQTtBQUtBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFGQTtBQUNBO0FBSUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUpBIiwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///0\n")}]);
<!DOCTYPE html>
<title>blockup</title>
<link href='dist.css' rel='stylesheet' />
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.5.1/leaflet.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.5.1/leaflet.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/mapbox-gl/1.2.0/mapbox-gl.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/mapbox-gl/1.2.0/mapbox-gl.css" rel="stylesheet" />
<script src="leaflet-mapbox-gl2.js"></script>
<body>
<div id="container">
<div id="map1"></div>
<div id="map2"></div>
</div>
<script src='dist.js'></script>
</body>
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['leaflet', 'mapbox-gl'], factory);
} else if (typeof exports === 'object') {
// Node, CommonJS-like
module.exports = factory(require('leaflet'), require('mapbox-gl'));
} else {
// Browser globals (root is window)
root.returnExports = factory(window.L, window.mapboxgl);
}
}(this, function (L, mapboxgl) {
L.MapboxGL = L.Layer.extend({
options: {
updateInterval: 32,
padding: 100
},
initialize: function (options) {
L.setOptions(this, options);
if (options.accessToken) {
mapboxgl.accessToken = options.accessToken;
} else {
throw new Error('You should provide a Mapbox GL access token as a token option.');
}
// setup throttling the update event when panning
this._throttledUpdate = L.Util.throttle(this._update, this.options.updateInterval, this);
},
onAdd: function (map) {
if (!this._glContainer) {
this._initContainer();
}
this.getPane().appendChild(this._glContainer);
this._initGL();
this._offset = this._map.containerPointToLayerPoint([0, 0]);
// work around https://github.com/mapbox/mapbox-gl-leaflet/issues/47
if (map.options.zoomAnimation) {
L.DomEvent.on(map._proxy, L.DomUtil.TRANSITION_END, this._transitionEnd, this);
}
},
onRemove: function (map) {
if (this._map._proxy && this._map.options.zoomAnimation) {
L.DomEvent.off(this._map._proxy, L.DomUtil.TRANSITION_END, this._transitionEnd, this);
}
this.getPane().removeChild(this._glContainer);
this._glMap.remove();
this._glMap = null;
},
getEvents: function () {
return {
move: this._throttledUpdate, // sensibly throttle updating while panning
zoomanim: this._animateZoom, // applys the zoom animation to the <canvas>
zoom: this._pinchZoom, // animate every zoom event for smoother pinch-zooming
zoomstart: this._zoomStart, // flag starting a zoom to disable panning
zoomend: this._zoomEnd
};
},
_initContainer: function () {
var container = this._glContainer = L.DomUtil.create('div', 'leaflet-gl-layer');
var size = this.getSize();
var padding = this.options.padding;
container.style.width = size.x + 'px';
container.style.height = size.y + 'px';
var topLeft = this._map.containerPointToLayerPoint([0, 0]).subtract([padding, padding]);
L.DomUtil.setPosition(container, topLeft);
},
_initGL: function () {
var center = this._map.getCenter();
var options = L.extend({}, this.options, {
container: this._glContainer,
interactive: false,
center: [center.lng, center.lat],
zoom: this._map.getZoom() - 1,
attributionControl: false
});
this._glMap = new mapboxgl.Map(options);
// allow GL base map to pan beyond min/max latitudes
this._glMap.transform.latRange = null;
if (this._glMap._canvas.canvas) {
// older versions of mapbox-gl surfaced the canvas differently
this._glMap._actualCanvas = this._glMap._canvas.canvas;
} else {
this._glMap._actualCanvas = this._glMap._canvas;
}
// treat child <canvas> element like L.ImageOverlay
L.DomUtil.addClass(this._glMap._actualCanvas, 'leaflet-image-layer');
L.DomUtil.addClass(this._glMap._actualCanvas, 'leaflet-zoom-animated');
},
_update: function (e) {
// update the offset so we can correct for it later when we zoom
this._offset = this._map.containerPointToLayerPoint([0, 0]);
if (this._zooming) {
return;
}
var size = this.getSize(),
container = this._glContainer,
gl = this._glMap,
padding = this.options.padding,
topLeft = this._map.containerPointToLayerPoint([0, 0]).subtract([padding, padding]);
L.DomUtil.setPosition(container, topLeft);
var center = this._map.getCenter();
// gl.setView([center.lat, center.lng], this._map.getZoom() - 1, 0);
// calling setView directly causes sync issues because it uses requestAnimFrame
var tr = gl.transform;
tr.center = mapboxgl.LngLat.convert([center.lng, center.lat]);
tr.zoom = this._map.getZoom() - 1;
if (gl.transform.width !== size.x || gl.transform.height !== size.y) {
container.style.width = size.x + 'px';
container.style.height = size.y + 'px';
if (gl._resize !== null && gl._resize !== undefined){
gl._resize();
} else {
gl.resize();
}
} else {
// older versions of mapbox-gl surfaced update publicly
if (gl._update !== null && gl._update !== undefined){
gl._update();
} else {
gl.update();
}
}
},
getSize: function () {
var padding = this.options.padding * 2;
return this._map.getSize().add([padding, padding])
},
// update the map constantly during a pinch zoom
_pinchZoom: function (e) {
this._glMap.jumpTo({
zoom: this._map.getZoom() - 1,
center: this._map.getCenter()
});
},
// borrowed from L.ImageOverlay https://github.com/Leaflet/Leaflet/blob/master/src/layer/ImageOverlay.js#L139-L144
_animateZoom: function (e) {
var scale = this._map.getZoomScale(e.zoom);
var padding = this.options.padding;
var viewHalf = this.getSize()._divideBy(2);
var topLeft = this._map.project(e.center, e.zoom)._subtract(viewHalf)._add(this._map._getMapPanePos().add([padding * scale, padding * scale]))._round();
var offset = this._map.project(this._map.getBounds().getNorthWest(), e.zoom)._subtract(topLeft);
L.DomUtil.setTransform(this._glMap._actualCanvas, offset.subtract(this._offset), scale);
},
_zoomStart: function (e) {
this._zooming = true;
},
_zoomEnd: function () {
var scale = this._map.getZoomScale(this._map.getZoom()),
offset = this._map._latLngToNewLayerPoint(this._map.getBounds().getNorthWest(), this._map.getZoom(), this._map.getCenter());
L.DomUtil.setTransform(this._glMap._actualCanvas, offset.subtract(this._offset), scale);
this._zooming = false;
this._update();
},
_transitionEnd: function (e) {
L.Util.requestAnimFrame(function () {
var zoom = this._map.getZoom(),
center = this._map.getCenter(),
offset = this._map.latLngToContainerPoint(this._map.getBounds().getNorthWest());
// reset the scale and offset
L.DomUtil.setTransform(this._glMap._actualCanvas, offset, 1);
// enable panning once the gl map is ready again
this._glMap.once('moveend', L.Util.bind(function () {
this._zoomEnd();
}, this));
// update the map position
this._glMap.jumpTo({
center: center,
zoom: zoom - 1
});
}, this);
}
});
L.mapboxGL = function (options) {
return new L.MapboxGL(options);
};
}));
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['leaflet', 'mapbox-gl'], factory);
} else if (typeof exports === 'object') {
// Node, CommonJS-like
module.exports = factory(require('leaflet'), require('mapbox-gl'));
} else {
// Browser globals (root is window)
root.returnExports = factory(window.L, window.mapboxgl);
}
}(this, function (L, mapboxgl) {
L.MapboxGL = L.Layer.extend({
options: {
updateInterval: 32,
// How much to extend the overlay view (relative to map size)
// e.g. 0.15 would be 15% of map view in each direction
padding: 0.15
},
initialize: function (options) {
L.setOptions(this, options);
if (options.accessToken) {
mapboxgl.accessToken = options.accessToken;
} else {
throw new Error('You should provide a Mapbox GL access token as a token option.');
}
// setup throttling the update event when panning
this._throttledUpdate = L.Util.throttle(this._update, this.options.updateInterval, this);
},
onAdd: function (map) {
if (!this._glContainer) {
this._initContainer();
}
this.getPane().appendChild(this._glContainer);
this._initGL();
this._offset = this._map.containerPointToLayerPoint([0, 0]);
// work around https://github.com/mapbox/mapbox-gl-leaflet/issues/47
if (map.options.zoomAnimation) {
L.DomEvent.on(map._proxy, L.DomUtil.TRANSITION_END, this._transitionEnd, this);
}
},
onRemove: function (map) {
if (this._map._proxy && this._map.options.zoomAnimation) {
L.DomEvent.off(this._map._proxy, L.DomUtil.TRANSITION_END, this._transitionEnd, this);
}
this.getPane().removeChild(this._glContainer);
this._glMap.remove();
this._glMap = null;
},
getEvents: function () {
return {
move: this._throttledUpdate, // sensibly throttle updating while panning
zoomanim: this._animateZoom, // applys the zoom animation to the <canvas>
zoom: this._pinchZoom, // animate every zoom event for smoother pinch-zooming
zoomstart: this._zoomStart, // flag starting a zoom to disable panning
zoomend: this._zoomEnd
};
},
_getSize: function () {
return this._map.getSize().multiplyBy(1 + this.options.padding * 2);
},
_initContainer: function () {
var container = this._glContainer = L.DomUtil.create('div', 'leaflet-gl-layer');
var size = this._getSize();
var offset = this._map.getSize().multiplyBy(this.options.padding);
container.style.width = size.x + 'px';
container.style.height = size.y + 'px';
var topLeft = this._map.containerPointToLayerPoint([0, 0]).subtract(offset);
L.DomUtil.setPosition(container, topLeft);
},
_initGL: function () {
var center = this._map.getCenter();
var options = L.extend({}, this.options, {
container: this._glContainer,
interactive: false,
center: [center.lng, center.lat],
zoom: this._map.getZoom() - 1,
attributionControl: false
});
this._glMap = new mapboxgl.Map(options);
// allow GL base map to pan beyond min/max latitudes
this._glMap.transform.latRange = null;
if (this._glMap._canvas.canvas) {
// older versions of mapbox-gl surfaced the canvas differently
this._glMap._actualCanvas = this._glMap._canvas.canvas;
} else {
this._glMap._actualCanvas = this._glMap._canvas;
}
// treat child <canvas> element like L.ImageOverlay
L.DomUtil.addClass(this._glMap._actualCanvas, 'leaflet-image-layer');
L.DomUtil.addClass(this._glMap._actualCanvas, 'leaflet-zoom-animated');
},
_update: function (e) {
// update the offset so we can correct for it later when we zoom
this._offset = this._map.containerPointToLayerPoint([0, 0]);
if (this._zooming) {
return;
}
var size = this._getSize(),
container = this._glContainer,
gl = this._glMap,
offset = this._map.getSize().multiplyBy(this.options.padding),
topLeft = this._map.containerPointToLayerPoint([0, 0]).subtract(offset);
L.DomUtil.setPosition(container, topLeft);
var center = this._map.getCenter();
// gl.setView([center.lat, center.lng], this._map.getZoom() - 1, 0);
// calling setView directly causes sync issues because it uses requestAnimFrame
var tr = gl.transform;
tr.center = mapboxgl.LngLat.convert([center.lng, center.lat]);
tr.zoom = this._map.getZoom() - 1;
if (gl.transform.width !== size.x || gl.transform.height !== size.y) {
container.style.width = size.x + 'px';
container.style.height = size.y + 'px';
if (gl._resize !== null && gl._resize !== undefined){
gl._resize();
} else {
gl.resize();
}
} else {
// older versions of mapbox-gl surfaced update publicly
if (gl._update !== null && gl._update !== undefined){
gl._update();
} else {
gl.update();
}
}
},
// update the map constantly during a pinch zoom
_pinchZoom: function (e) {
this._glMap.jumpTo({
zoom: this._map.getZoom() - 1,
center: this._map.getCenter()
});
},
// borrowed from L.ImageOverlay https://github.com/Leaflet/Leaflet/blob/master/src/layer/ImageOverlay.js#L139-L144
_animateZoom: function (e) {
var scale = this._map.getZoomScale(e.zoom);
var padding = this._map.getSize().multiplyBy(this.options.padding * scale);
var viewHalf = this._getSize()._divideBy(2);
// corrections for padding (scaled), adapted from
// https://github.com/Leaflet/Leaflet/blob/master/src/map/Map.js#L1490-L1508
var topLeft = this._map.project(e.center, e.zoom)
._subtract(viewHalf)
._add(this._map._getMapPanePos()
.add(padding))._round();
var offset = this._map.project(this._map.getBounds().getNorthWest(), e.zoom)
._subtract(topLeft);
L.DomUtil.setTransform(this._glMap._actualCanvas, offset.subtract(this._offset), scale);
},
_zoomStart: function (e) {
this._zooming = true;
},
_zoomEnd: function () {
var scale = this._map.getZoomScale(this._map.getZoom()),
offset = this._map._latLngToNewLayerPoint(this._map.getBounds().getNorthWest(), this._map.getZoom(), this._map.getCenter());
L.DomUtil.setTransform(this._glMap._actualCanvas, offset.subtract(this._offset), scale);
this._zooming = false;
this._update();
},
_transitionEnd: function (e) {
L.Util.requestAnimFrame(function () {
var zoom = this._map.getZoom(),
center = this._map.getCenter(),
offset = this._map.latLngToContainerPoint(this._map.getBounds().getNorthWest());
// reset the scale and offset
L.DomUtil.setTransform(this._glMap._actualCanvas, offset, 1);
// enable panning once the gl map is ready again
this._glMap.once('moveend', L.Util.bind(function () {
this._zoomEnd();
}, this));
// update the map position
this._glMap.jumpTo({
center: center,
zoom: zoom - 1
});
}, this);
}
});
L.mapboxGL = function (options) {
return new L.MapboxGL(options);
};
}));
{
"standard": {
"globals": [
"d3"
]
},
"dependencies": {
"leaflet": "^1.5.1",
"mapbox-gl": "^1.2.0"
}
}
const map1 = L.map("map1", {
center: [40, -74.50],
zoom: 9
});
var gl1 = L.mapboxGL({
attribution: '<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>',
accessToken: 'not-needed',
style: 'https://api.maptiler.com/maps/basic/style.json?key=WrAno2oMdcq6tir0zRa8'
}).addTo(map1);
// mapboxgl.accessToken = 'pk.eyJ1IjoidzhyIiwiYSI6IlF2Nlh6QVkifQ.D7BkmeoMI7GEkMDtg3durw';
// const glmap = new mapboxgl.Map({
// container: 'map2', // container id
// style: 'mapbox://styles/mapbox/streets-v10', // stylesheet location
// center: [-74.50, 40], // starting position [lng, lat]
// zoom: 8 // starting zoom
// });
const map2 = L.map('map2', {
center: [40, -74.50],
zoom: 9
});
var gl2 = L.mapboxGL({
attribution: '<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>',
accessToken: 'not-needed',
style: 'https://api.maptiler.com/maps/basic/style.json?key=WrAno2oMdcq6tir0zRa8',
padding: 0
}).addTo(map2);
*
box-sizing border-box
html, body
margin 0
padding 0
width 100%
height 100%
#container
display flex
flex-direction row
height 100%
#map1, #map2
width 50%
height 100%
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment