关于mapbox:简单使用-MapboxGL-自定义图层绘制带贴图的矩形

写文目标不是常常用,可能会遗记根本用法,自留。 贴图 成果 留神点形成矩形的两个三角形须要应用 mapboxgl.MercatorCoordinate.fromLngLat 办法转换至 WebGL 中的场景世界坐标,留神案例中是如何设置三角形程序的,要逆时针render 函数是每一帧绘制的调用函数,每一帧都要设置一次 program、texture、vertexBuffer,能力触发 draw,并且尤为留神矩阵 uniform 的地址,也是每一帧都要获取最新的(gl.getUniformLocation(this.program, 'u_matrix'))贴图的长宽尺寸要用 2 的次幂源码拜访令牌请应用本人的,代码不做解释,根本的正文已齐备。 页面局部(省略,请本人补全): <link href="https://api.mapbox.com/mapbox-gl-js/v2.9.1/mapbox-gl.css" rel="stylesheet"><script src="https://api.mapbox.com/mapbox-gl-js/v2.9.1/mapbox-gl.js"></script><style>#map { width: 90vw; height: 90vh;}</style><div id="map"></div><script> // js 局部,见下</script>js 局部: mapboxgl.accessToken = '你的令牌'const map = new mapboxgl.Map({ container: 'map', zoom: 7, center: [112.5, 22.5], style: 'mapbox://styles/mapbox/light-v10', // 开启 WebGL 的 msaa(抗锯齿) antialias: true})const vertexSource = /* glsl */`uniform mat4 u_matrix;attribute vec3 a_pos;attribute vec2 a_uv;varying vec2 v_uv;void main() { v_uv = a_uv; gl_Position = u_matrix * vec4(a_pos, 1.0);}`const fragmentSource = /* glsl */`precision mediump float;varying vec2 v_uv;uniform sampler2D u_sampler;void main() { gl_FragColor = texture2D(u_sampler, v_uv);}`// create a custom style layer to implement the WebGL contentconst customLayer = { id: 'highlight', type: 'custom', // 当图层增加时调用的函数 // https://docs.mapbox.com/mapbox-gl-js/api/#styleimageinterface#onadd onAdd: function (map, /** @type {WebGLRenderingContext} */gl) { // 创立、编译顶点着色器 const vertexShader = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vertexShader, vertexSource) gl.compileShader(vertexShader) // 创立、编译片元着色器 const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(fragmentShader, fragmentSource) gl.compileShader(fragmentShader) // 创立着色器程序,链接片元和顶点着色器 this.program = gl.createProgram() gl.attachShader(this.program, vertexShader) gl.attachShader(this.program, fragmentShader) gl.linkProgram(this.program) // 把 vertexAttributes 和 uniform 在着色器中的地位保留下来 this.aPos = gl.getAttribLocation(this.program, 'a_pos') this.aUv = gl.getAttribLocation(this.program, 'a_uv') this.uSamplerLoc = gl.getUniformLocation(this.program, 'u_sampler') // 四个点用于定义一个矩形 const p1 = mapboxgl.MercatorCoordinate.fromLngLat({ lng: 112.5494384765625, lat: 22.268764039073968 }, 10) const p2 = mapboxgl.MercatorCoordinate.fromLngLat({ lng: 114.0216064453125, lat: 22.268764039073968 }, 10) const p3 = mapboxgl.MercatorCoordinate.fromLngLat({ lng: 114.0216064453125, lat: 23.28171917560002 }, 10) const p4 = mapboxgl.MercatorCoordinate.fromLngLat({ lng: 112.5494384765625, lat: 23.28171917560002 }, 10) // 加载贴图并创立纹理,在加载结束的回调函数中上载贴图数据 const img = new Image() img.src = './img.png' this.texture = gl.createTexture() img.onload = () => { // bind 和 storei 操作必须等纹理解码实现 gl.bindTexture(gl.TEXTURE_2D, this.texture) gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) // 上载数据 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img) } // 创立 VBO this.buffer = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([ // position, uv p1.x, p1.y, p1.z, 0, 0, p2.x, p2.y, p2.z, 1, 0, p3.x, p3.y, p3.z, 1, 1, p1.x, p1.y, p1.z, 0, 0, p3.x, p3.y, p3.z, 1, 1, p4.x, p4.y, p4.z, 0, 1, ]), gl.STATIC_DRAW ) }, // 每帧运行 // https://docs.mapbox.com/mapbox-gl-js/api/#map.event:render render: function (/** @type {WebGLRenderingContext} */gl, matrix) { // 每帧都要指定用哪个着色器程序 gl.useProgram(this.program) // 每帧都要传递 uniform gl.uniformMatrix4fv( gl.getUniformLocation(this.program, 'u_matrix'), false, matrix ) // 每帧都要绑定纹理参数 gl.bindTexture(gl.TEXTURE_2D, this.texture) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) // 每帧都要绑定 VBO,并启用 vertexAttributes、设置 vertexAttributes 的参数 gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) gl.enableVertexAttribArray(this.aPos) gl.enableVertexAttribArray(this.aUv) gl.vertexAttribPointer(this.aPos, 3, gl.FLOAT, false, 20, 0) gl.vertexAttribPointer(this.aUv, 2, gl.FLOAT, false, 20, 12) // 如果你用不着透明度,能够不执行这两行 // gl.enable(gl.BLEND) // gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) // 触发绘制 gl.drawArrays(gl.TRIANGLES, 0, 6) }}map.on('load', () => { map.addLayer(customLayer)})可改良点上述例子在增加进 map 之后,图片还是没有申请、解码实现的,其回调函数还没有执行,如果你不去动一下地图,比方点击一下或挪动一下视角,是不会更新的,有可能是黑黑的一个框 —— 纹理还没筹备好,只能画黑框框。 ...

July 20, 2022 · 2 min · jiezi

关于mapbox:mapboxgl-地图样式-重分类渲染

上回,咱们在《mapboxgl 地图款式 - 惟一值渲染》中理解到case、match、get等表达式,通过表达式来实现了惟一值渲染。 在理论状况下,咱们还常常须要进行重分类渲染,将某范畴的值重分为一类,并将另一个范畴重分为其它类。 明天咱们持续理解新的表达式来实现重分类渲染。 重分类效果图: 形式一:应用step表达式"fill-color":[ "step", ["get","population"], "#ffd0a6", 50, "#ffaa7f", 100, "#ff704e", 150, "#f04040", 200, "#b50a09"]下面表达式的意思是: get获取属性值population,小于50,色彩是#ffd0a6大于等于50,但小于100时,色彩是#ffaa7f大于等于100,但小于150时,色彩是#ff704e大于等于150,但小于200时,色彩是#f04040大于等于200,色彩是#b50a09看到这里是不是奇怪step是起什么作用的?step译为步,一步一步就是分段的意思,它产生阶梯式后果,把一段值归为一类,小于50的是一类,大于等于50又小于100是一类,每一类step都会输入一个值,在效果图中展现为50万人口以下地区是一个色彩,大于等于50又小于100万人口区间的地区为另一个色彩。 咱们在下篇渐变色渲染的文章中将会理解interpolate表达式,与step表达式产生的阶梯式后果相同,interpolate表达式将会产生间断后果。翻译成js是: function getColor(feature){ //feature是geojosn格局中的Feature if(feature.properties.population<50){ return '#ffd0a6' } else if(feature.properties.population<100){ return '#ffaa7f' } ... else{ return '#b50a09' }}step表达式语法规定: "step"是表达式的名称["get","adcode"]是输出值,必须为数值类型或者是数值表达式"#ffd0a6"是输入值50是判断值...(依据理论状况两两呈现的输入值、判断值)"#b50a09"是输入值step表达式有5个必须参数,并且不能乱序:表达式的名称、输出值、输入值、判断值,... ...,输入值(省略局部为输入值、判断值,在省略局部里如果呈现了,就必须两两呈现)。也就是说除了表达式的名称和输出值以外,起码还须要一个输入值、一个判断值、再加一个输入值。 //必须参数"fill-color":[ "step", //表达式的名称 ["get","population"], //输出值 "#ffd0a6", //输入值 50, //判断值 "#b50a09" //输入值]step表达式写起来较为简洁,但须要留神的一点是判断值必须遵循升序规定。 在线示例:http://gisarmory.xyz/blog/index.html?demo=mapboxglStyleReclass2 形式二:应用 case 表达式case表达式相似js里的if判断语句。表达式的实现成果比拟依赖于属性值,通常咱们先应用get表达式去获取属性值,再去判断这个属性值,以此达到在同一图层上实现不同的展现成果。 "fill-color":[ "case", ['boolean',['<',["get","population"],50]],"#ffd0a6", ['boolean',['<',["get","population"],100]],"#ffaa7f", ['boolean',['<',["get","population"],150]],"#ff704e", ['boolean',['<',["get","population"],200]],"#f04040", '#b50a09']下面表达式的意思是: t获取属性值population,小于50,色彩是#ffd0a6大于等于50,但小于100时,色彩是#ffaa7f大于等于100,但小于150时,色彩是#ff704e大于等于150,但小于200时,色彩是#f04040大于等于200,色彩是#b50a09在线示例:http://gisarmory.xyz/blog/index.html?demo=mapboxglStyleReclass1 翻译成js是: function getColor(feature){ //feature是geojosn格局中的Feature if(feature.properties.population<50){ return '#ffd0a6' } else if(feature.properties.population<100){ return '#ffaa7f' } ... else{ return '#b50a09' }}case表达式写起来较为繁琐,但它对判断值没有升序这种要求,只有是true或false就行了。 ...

March 11, 2022 · 1 min · jiezi

关于mapbox:mapbox地图对接gis图层时坐标系不一致

问题形容当我的项目须要对接GIS图层时,发现它的坐标系为EPSG:4490,而mapbox反对的坐标系是EPSG:3857。mapbox对接wms时bbox截取的地图块的经纬度范畴,在对接时可将其EPSG:3857通过拦挡申请转换为EPSG:4490的坐标系,获取失去GIS对应的图层瓦片。 EPSG:4326大地坐标系,WGS84EPSG:4490大地坐标系,cgcs2000EPSG:3857投影坐标系,墨卡托投影形式1: 通过全局拦挡fetch申请的形式function injectFetch() { const newFetch = Object.getOwnPropertyDescriptor(window, 'fetch'); Object.defineProperty(window, 'fetch', { value(a, b) { if (a instanceof Request && a.url.includes('/GISServices/')) { const u = new URL(a.url); // 解析 bbox 参数 const bbox = u.searchParams.get('bbox') ?? ''; const [lon1, lat1, lon2, lat2] = bbox.split(','); // 坐标系转换 : 墨卡托->GPS const p1 = mercator2LonLat([Number(lon1), Number(lat1)]); const p2 = mercator2LonLat([Number(lon2), Number(lat2)]); const newBBOX = p1.concat(p2).join(','); u.searchParams.set('bbox', newBBOX); Object.defineProperty(a, 'url', { value: decodeURIComponent(u.toString()), }); } return newFetch.value.apply(this, [a, b]); }, });}代理拦挡申请的形式可通过node写一个代理服务,拦挡瓦片申请服务。参考webpack的配置代理服务的形式,通过http-proxy-middleware来实现,而后可通过把node服务打包为exe后部署服务器来实现。坐标系简介通常有两种坐标系 天文坐标系(geographic coordinate systems) 和 投影坐标系(projected coordinate systems) ...

February 19, 2022 · 1 min · jiezi

关于mapbox:mapbox自定义样式根据高度着色

mapbox依据地形高度渲色彩这里分为两种形式来实现,别离对应实用于设计师的mapbox studio配置形式,和实用于开发者的代码更改形式,常规先看最终成果 查看交互式的最终成果实用于设计人员的形式先新建一个地图,根底款式任意抉择 先设置高度,这样就有了线稿个别的轮廓 而后咱们新建一个图层,留神这一步抉择如图所示的内容能够拿到地形高度信息,随后在type里设置为fill-extrusion 而后设置色彩就和上色一样,规定由您任意定制 实用于开发者的形式须要留神如果您应用了本人的DEM源,您须要更改源和高度获取形式为您所应用数据源的配套值map.on('load',()=>{ map.addSource('mapbox-dem', { "type": "vector", "url": "mapbox://mapbox.mapbox-terrain-v2" }); map.addLayer({ id:'tinting-layer', 'source': 'mapbox-dem', 'source-layer': 'contour', 'type': 'fill-extrusion', paint:{ "fill-extrusion-color":[ "interpolate", ["linear"], ["get", "ele"], -410, "hsl(253, 98%, 42%)", 0, "hsl(237, 94%, 49%)", 1000, "hsl(213, 96%, 64%)", 2000, "hsl(177, 100%, 50%)", 3000, "hsl(119, 100%, 53%)", 4000, "hsl(98, 98%, 69%)", 5000, "hsl(69, 95%, 56%)", 6000, "hsl(27, 95%, 56%)", 7000, "hsl(0, 96%, 64%)" ], "fill-extrusion-height":[ "interpolate", ["linear"], ["get", "ele"], 0, 0, 8840, 8840 ] } })})查看交互式的最终成果

December 19, 2021 · 1 min · jiezi

关于mapbox:mapboxgl-互联网地图纠偏插件二

前段时间写的mapboxgl 互联网地图纠偏插件(一)存在地图旋转时瓦片错位的问题。 这次没有再跟 mapboxgl 的变换矩阵较劲,而是另辟蹊径应用 mapboxgl 的自定义图层,从新写了一套加载瓦片的办法来实现地图纠偏。 上面把我这次打怪降级的心路历程分享一下,或者对你也有启发。 文中波及一些 webgl 的常识细节,没有接触过 webgl 的同学,能够参考看上一次给大家举荐的电子书 《WebGL编程指南》,这次再附上一个蕴含书中所有示例的 github 库,会很有帮忙。 书接上回在钻研偏移矩阵问题束手无策时,发现用天地图的栅格瓦片没有偏移的问题,因为天地图是大地2000坐标,能够间接在 wgs84 坐标地图上应用,根本没有误差。 尝试后感觉,能够倒是能够,但就是配色有点丑,能够先作为一个保底计划,高德瓦片的纠偏还要持续钻研。 话说《WebGL编程指南》这本书看完后,始终想写个读书笔记,但又感觉光写笔记太干燥,就想着联合地图看无能点啥。 mapboxgl 通过自定图层接口反对 webgl 的扩大,这个接口的益处是,对简单的变换矩阵进行了封装,对外应用大家相熟的 web 墨卡托坐标,并提供了经纬度坐标和 web墨卡托坐标转换的接口 。 查看 mapboxgl 的官网示例时,忽然来了灵感,能够用这个接口本人写个加载栅格瓦片的程序,这样就能绕开 mapboxgl 简单的框架,更容易实现对瓦片纠偏,呈现问题也更好解决,对整体更有掌控感。 技术路线剖析: 用这个思路来实现纠偏,要搞定两大问题,一个是如何用 webgl 实现显示瓦片的性能,另一个是如何计算瓦片在屏幕上的显示地位。 如何用 webgl 显示瓦片在 webgl 中,图形的根底是三角形,要绘制正方形的瓦片,须要用两个三角形拼成一个正方形,再把图片贴到这个正方形上,就能实现地图瓦片的显示。这个过程中,图片被称为纹理,贴图被称为纹理贴图。实现成果如下(图片地位是轻易写的): 这里有两点要留神: 1、要留神图片的跨域问题,须要通过设置图片的跨域属性来解决。 2、要留神顶点坐标的程序,正确的程序为:左上、左下、右上、右下,不然图片会像穿衣服一样,各种穿反,前后反,左右反 外围代码如下: var picLoad = false; var tileLayer = { id: 'tileLayer', type: 'custom', //增加图层时调用 onAdd: function (map, gl) { var vertexSource = "" + "uniform mat4 u_matrix;" + "attribute vec2 a_pos;" + "attribute vec2 a_TextCoord;" + "varying vec2 v_TextCoord;" + "void main() {" + " gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);" + " v_TextCoord = a_TextCoord;" + "}"; var fragmentSource = "" + "precision mediump float;" + "uniform sampler2D u_Sampler; " + "varying vec2 v_TextCoord; " + "void main() {" + " gl_FragColor = texture2D(u_Sampler, v_TextCoord);" + "}"; //初始化顶点着色器 var vertexShader = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(vertexShader, vertexSource); gl.compileShader(vertexShader); //初始化片元着色器 var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(fragmentShader, fragmentSource); gl.compileShader(fragmentShader); //初始化着色器程序 var program = this.program = gl.createProgram(); gl.attachShader(this.program, vertexShader); gl.attachShader(this.program, fragmentShader); gl.linkProgram(this.program); //获取顶点地位变量 var a_Pos = gl.getAttribLocation(this.program, "a_pos"); var a_TextCoord = gl.getAttribLocation(this.program, 'a_TextCoord'); //设置图形顶点坐标 var leftTop = mapboxgl.MercatorCoordinate.fromLngLat({lng: 110,lat: 40}); var rightTop = mapboxgl.MercatorCoordinate.fromLngLat({lng: 120,lat: 40}); var leftBottom = mapboxgl.MercatorCoordinate.fromLngLat({lng: 110,lat: 30}); var rightBottom = mapboxgl.MercatorCoordinate.fromLngLat({lng: 120,lat: 30}); //顶点坐标放入webgl缓冲区中 var attrData = new Float32Array([ leftTop.x, leftTop.y, 0.0, 1.0, leftBottom.x, leftBottom.y, 0.0, 0.0, rightTop.x, rightTop.y, 1.0, 1.0, rightBottom.x, rightBottom.y, 1.0, 0.0 ]) var FSIZE = attrData.BYTES_PER_ELEMENT; this.buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); gl.bufferData(gl.ARRAY_BUFFER, attrData, gl.STATIC_DRAW); //设置从缓冲区获取顶点数据的规定 gl.vertexAttribPointer(a_Pos, 2, gl.FLOAT, false, FSIZE * 4, 0); gl.vertexAttribPointer(a_TextCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2); //激活顶点数据缓冲区 gl.enableVertexAttribArray(a_Pos); gl.enableVertexAttribArray(a_TextCoord); var _this = this; var img = this.img = new Image(); img.onload = () => { // 创立纹理对象 _this.texture = gl.createTexture(); //向target绑定纹理对象 gl.bindTexture(gl.TEXTURE_2D, _this.texture); //对纹理进行Y轴反转 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); //配置纹理图像 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.img); picLoad = true; }; img.crossOrigin = true; //设置容许跨域 img.src = "http://webrd02.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=843&y=386&z=10"; }, //渲染,地图界面变动时会调用这个办法,会调用若干次(变动时的每一帧都调用) render: function (gl, matrix) { if(picLoad){ //利用着色程序 //必须写到这里,不能写到onAdd中,不然gl中的着色程序可能不是下面写的,会导致上面的变量获取不到 gl.useProgram(this.program); //向target绑定纹理对象 gl.bindTexture(gl.TEXTURE_2D, this.texture); //开启0号纹理单元 gl.activeTexture(gl.TEXTURE0); //配置纹理参数 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT); // 获取纹理的存储地位 var u_Sampler = gl.getUniformLocation(this.program, 'u_Sampler'); //将0号纹理传递给着色器 gl.uniform1i(u_Sampler, 0); //给地位变换矩阵赋值 gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "u_matrix"), false, matrix); //绘制图形 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } } }; map.on('load', function () { map.addLayer(tileLayer); });下面是加载一个瓦片,上面看一下如何加载多个瓦片,这个问题看似简略,但对于webgl不相熟的同学有可能会走弯路,我本人在钻研时,就遇到了上面几个问题: ...

July 6, 2021 · 3 min · jiezi

关于mapbox:mapboxgl绘制3D线

最近遇到个需要,应用mapboxgl绘制行政区划图层,要求把行政区划拔高做出平面成果,以便突出显示。 拿到这个需要后,感觉很简略呀,只须要用fill-extrusion形式绘制就能够啦,实现进去是这个样子的 成果有点丑,并且这里有个问题就是我的数据是区县数据,而绘制出的成果却没有辨别出各个区县的边界于是从上面两个方向做优化解决: 各区县按不同色彩辨别增加区县边界首先尝试不同区县按色彩辨别。这个简略,只须要设置fill-extrusion-color即可 设置完成果如下 成果好多了。 接下来持续尝试增加区县边界,之前也看到过相似成果,感觉应该也好实现 然鹅。。。 通过一番查找,发现发现mapboxgl能够对面进行拔高解决,但没有对线做拔高解决的办法,也就是说不反对绘制3D线。这可如何是好 既然线数据不能做拔高解决,那么把线解决成面总能够吧 于是从这个思路登程,按上面两步来操作 对行政区划边界进行缓冲,这里须要用到turf.js的缓冲办法获取到缓冲后的边界面数据,再用fill-extrusion形式绘制 嗯,成果还不错 在绘制边线缓冲面时,须要留神上面两点: fill-extrusion-height设置的值须要比面数据的略微高出一些,否则显示时会有遮蔽问题fill-extrusion-base(底部高度)参数能够设置为与后面绘制面的fill-extrusion-height参数统一,如果也采纳默认0的话,边线就像一面墙,会感觉很丑其实,这里还有个问题,因为这里的边界是按缓冲面的形式绘制,所以在地图缩放的时候边界的宽度不会像线那样按固定像素宽度显示,会呈现放大地图的时候边线越来越宽,放大地图的时候边线变越来越窄的问题 解决思路:按各层级分辨率别离对行政区划边界做缓冲计算,而后再别离对应显示在各个层级 各层级的resolutions,也就是各比例尺下地图分辨率,也就是一个像素代表的地图单位,这里要按米为单位进行缓冲,用的是EPSG:900913的分辨率,也就是各比例尺下一个像素代表多少米外围代码如下 因为拿到数据的只有行政区划的geojson格局面数据,而缓冲时须要用的是线数据,因而须要做面转线解决。 最终成果如下 总结当对行政区划面数据做平面展现时,仅用fill-extrusion形式绘制,成果不好,无奈显示边线mapboxgl能够对面进行拔高解决,但没有对线做拔高解决的办法,也就是说不反对绘制3D线通过对边线数据缓冲,获取缓冲前面数据,当做边线应用因为单次缓冲半径固定,从而失去的缓冲面对大小固定,会呈现地图缩放边线宽度也随着缩放的问题通过resolutions,逐级对边线解决,按层级显示,能够失去较好的显示成果在线示例在线示例:http://gisarmory.xyz/blog/index.html?demo=MapboxGL3DLine 代码地址:http://gisarmory.xyz/blog/index.html?source=MapboxGL3DLine 原文地址:http://gisarmory.xyz/blog/index.html?blog=MapboxGL3DLine 关注《GIS兵器库》, 只给你网上搜不到的GIS常识技能 本文章采纳 常识共享署名-非商业性应用-雷同形式共享 4.0 国内许可协定 进行许可。欢送转载、应用、从新公布,但务必保留文章署名《GIS兵器库》(蕴含链接:  http://gisarmory.xyz/blog/),不得用于商业目标,基于本文批改后的作品务必以雷同的许可公布。

July 2, 2021 · 1 min · jiezi

⭐Mapbox-GL-JS学习探索系列2-Source

简介在mapbox中对于地图,图层的呈现都依托于相应的数据源去渲染。mapbox 中的数据源一般分为vector, raster, raster-dem, geojson, image, video这六种类型,本文依次对这6中资源类型进行简单介绍。 vector 与 raster矢量瓦片与栅格瓦片。关于地图的瓦片加载,在上一篇文章中有介绍,这里简单说一下矢量与栅格的区别。在gis 中 矢量瓦片与栅格瓦片的关系,类似于计算机图形中的矢量图和点阵图的关系,vector是通过点线面这三种基础模型,然后在地图的横纵坐标上进行绘制呈现,而raster则是通过像素点来对地图进行绘制。 vector优点:因为不同于raster通过像素点绘制,因此不会出现放大后地图变模糊的情况。vector缺点:在数据存储过程中,运算相对较多。不能存储高程数据(DEM)来对地理特征做表示。raster优点:每个单元格的地理信息都是很明确的,因此去做建模和数据分析都比较方便。raster缺点:单元格数据大小决定分辨率,因此容易出现模糊,不能较好的表示地图显示的线性特征。pdf 即是地图的瓦片数据。 raster DEM栅格瓦片 - 数字高程模型。因为raster这种数据源对于地图位置能有较好的表示,在此基础上,可以增加对于地表特征的描述,应用场景为地形地貌的分析描述。关于 DEM的详细介绍。 geojsongeojson 是gis中常用到表示地理信息的数据格式,对于点线面等基本图形,都有其标准的表示方法。比如在地图中对于一个点的数据表示方法 { type: 'geojson', data: { "type": "FeatureCollection", "features": [{ "type": "Feature", "bbox": [-180.0, -90.0, 180.0, 90.0], "properties": {}, "geometry": { "type": "Point", "coordinates": [ -76.53063297271729, 39.18174077994108 ] } }] }}在geometry中的type,具体表述了数据的类型,可以是点,线,以及面。具体有:"Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"。 在feature这一层级可以加入bbox属性来表示这一个特征值的边界,通常为左上右下的两个x,y坐标。 通常在实际开发应用的,还会使用turf.js 这个空间坐标的类库,来提高开发效率,这个库提供了空间地理坐标常用的一些方法,非常好用。 在geojson这里介绍一个cluster属性,这是一个聚合属性,在开启这个属性之后,图层会检测数据渲染之后是否该聚合(变相的碰撞检测),可以通过设置clusterRadius来控制图层数据间的显示隐藏的距离。在实际开发中对于图层的处理方面,有很大帮助,在之后介绍layer的文章中会用一个实际是范例来讲解。 image,video能够直接在地图中引入图片和视频,本人在实际工作中接触的较少,就不展开说明了。 以上就是mapbox的数据源的简单介绍,其中geojson是使用频率最高的,也是在对地图进行二次构造中,最为灵活易用的数据类型。

October 13, 2019 · 1 min · jiezi

⭐Mapbox-GL-JS学习探索系列1-Map

摘要本文旨在分享自己在mapbox的学习过程中的使用经验,通过介绍mapbox的一些实际应用与概念,来记录自己的学习路程与经验分享,希望帮助更多对mapbox有兴趣的同学来共同进步。 地图预备知识在实际接触mapbox之前,需要对地图有一定的认知,这对于之后在实际开发中会有很大的帮助。瓦片地图:为了达到更快的地图加载效率,地图资源大多以瓦片的形式加载,即在不同的缩放等级下,来去服务器获取所需的瓦片资源,关于瓦片原理更详细的介绍。坐标系:现在常用的坐标系一般分为三种,WGS84(mapbox,谷歌),GCJ-02(高德,腾讯),BD-09(百度),这三种坐标系可以相互转化。 地图对象通过JS去生成一个地图,必要的属性只有2个,一个就是地图在html中的容器,即装载地图div的ID,另一个就是地图的样式,地图样式一般包括渲染地图的资源以及缩放,中心点等地图配置信息。(关于地图显示和更多详情参考文档示例) 地图事件地图上有很多属性方法,之后的文章会挑其中常用,重点的进行详细讨论,这里只介绍一下地图的方法订阅。mapbox 的方法操作主要有三个,在实例化地图得到map对象后即: var map = new mapboxgl.Map({container: 'map', // container idstyle: 'mapbox://styles/mapbox/streets-v11', // stylesheet locationcenter: [-74.50, 40], // starting position [lng, lat]zoom: 9 // starting zoom});以点击事件为例,在这个map对象上面调用: map.on('click',() => {})map.off('click',() => {})map.fire('click')on:这个方法接受三个参数,订阅事件类型(click),事件绑定图层layerId(非必填),事件订阅回调函数。off:方法与on接受同样的参数,作用是取消绑定在地图(图层)上的事件方法。fire:mapbox文档中没有写明这个方法,但是这个方法也非常好用,作用是主动触发订阅的方法,在mapbox-gl.js做扩展的时候,这个方法作用非常大,因为mapbox的方法订阅是标准的发布订阅模式,所以只需要map.fire("xxx") 就可以主动触发之前订阅的一些方法(包括自定义的一些方法到mapbox当中)。这里重点介绍两个方法,load 和 data。 load表示的是地图必要资源加载且渲染完成后,触发的方法。这里之前遇到一个坑是在这个方法中调用了地图楼层和一些区域配置中的一些参数,诸如bbox等等,没有成功,原因是这些配置项并不属于地图必须加载的资源配置,因此在使用这个方法中要格外注意,如果有额外的自定义的样式资源请求,就可以使用map.on 订阅一个自己的loaded方法,然后在相关资源加载完毕之后使用fire 触发自定义的方法。 data表示的是地图资源放生改变时触发的方法,这个方法在图层渲染,资源更改时使用频率非常高,因为load只是首次触发的方法,在后续对地图(图层)资源进行修改的过程中,需要使用data方法来就行判定,在这个方法中返回的是一个 里面包含了数据类型等信息,这个在实际开发中的使用场景也很多,之后在介绍layer板块的时候,会举一个data方法的实际用例。 小结本文没有从0到1的去讲解一个地图怎样渲染,因为官方文档都有明确的示例,这里更多的是通过自己在工作和实践中遇到的问题,来映射出一些地图的基础概念与一些方法总结,完全没有概念的同学可能需要先去mapbox文档了解一下。下一篇会写mapbox 图层(layer)这块。

October 6, 2019 · 1 min · jiezi

[译] 解密 Mapbox 卫星影像处理神器 Robosat

英文原文地址:https://www.openstreetmap.org…英文原文作者:daniel前言最近,Mapbox 开源了端到端的卫星影像特征提取工具 RoboSat。下面我将以来自 OpenAerialMap 的 Tanzania 区域的无人机影像 演示如何在自定义图像数据集上运行完整的RoboSat流程。目标我们的目标是通过手把手的教学,在Dar es Salaam 和 Zanzibar 附近提取建筑物轮廓。我建议手工你过一遍 Zanzibar Mapping Initiative 和 OpenAerialMap 来建立对数据集的直观先验了解。整体步骤从无人机影像提取建筑轮廓包含下面几个步骤:数据准备:创建训练数据集训练模型:分割模型提取特征后处理:将分割结果转为简单的几何格式首先,带你创建一个基于 OpenAerialMap 的无人机影像并且带有 OpenStreetMap 建筑物轮廓蒙版的数据集。接着,我将演示如何训练 RoboSat 分割模型在新的无人机影像如何提取建筑。最后,我将介绍略好将预测结果转化为简单的多边形并映射到 OpenStreetMap 上。数据准备Zanzibar Mapping Initiative 通过OpenAerialMap提供他们的无人机影像。这是一个你可以手动浏览地图。训练RoboSat 的分割模型需要同时提供对应的 Slippy Map tiles 格式的无人机影像和对应的建筑物轮廓数据集。你可以认为这些由二进制组成的蒙版是用来标记建筑物对应区域是否存在建筑。让我们开始提取 Dar es Salaam 和 Zanzibar 边界框吧。从对应的无人机图像范围中提取OSM建筑物几何图形提取作为训练数据集,因此需要在 OSM 上截取对应的区域。我有个在 GeoFabrik 工作的朋友提供了方便又先进的 提取工具 osmium-tool ,它可以帮助实现上述任务。wget –limit-rate=1M http://download.geofabrik.de/africa/tanzania-latest.osm.pbfosmium extract –bbox ‘38.9410400390625,-7.0545565715284955,39.70458984374999,-5.711646879515092’ tanzania-latest.osm.pbf –output map.osm.pbf完美! Dar es Salaam and Zanzibar 的建筑物几何图形存储在 map.osm.pbf 中。RoboSot 利用 rs extract 来从 OSM 的底图提取几何图形。rs extract –type building map.osm.pbf buildings.geojson现在我们从建筑物几何图形的 buildings.geojson 文件提取对应的 Slippy Map tiles 文件,通常设置 zoom level 在19 或者 20 是比较合理的。rs cover –zoom 20 buildings.geojson buildings.tiles基于 buildings.tiles 文件我们可以从 OAM 下载无人机影像并且将它栅格化成对应的OSM蒙版瓦片,下面是一个训练数据的预览效果:如果你自己看会发现这些蒙版其实不是完美匹配的,因为我们训练的模型基于千万个图像和蒙版,有一些噪音数据还是可以接受的。创建无人机图像瓦片最简单的方式是通过 OAM 的 API。 我们可以用 /meta 接口请求所有开放的给定区域的无人机影像。http ‘https://api.openaerialmap.org/meta?bbox=38.9410400390625,-7.0545565715284955,39.70458984374999,-5.711646879515092'JSON 数组是请求的响应,她包含了所有边界框中的无人机影像元信息。我们可以用 jq 来过滤这些网络请求响应,结合比如 通过用户名或者日期这样的过滤条件。jq ‘.results[] | select(.user.name == “ZANZIBAR MAPPING INITIATIVE”) | {user: .user.name, date: .acquisition_start, uuid: .uuid}‘它将返回一个包含对应GeoTIFF图像的 JSON 对象{ “user”: “ZANZIBAR MAPPING INITIATIVE”, “date”: “2017-06-07T00:00:00.000Z”, “uuid”: “https://oin-hotosm.s3.amazonaws.com/5ac7745591b5310010e0d49a/0/5ac7745591b5310010e0d49b.tif"}现在我们有两个选项下载 GeoTIFF 然后用 rasterio 和 rio-tiler 小工具来切割 GeoTIFF生成对应的瓦片。请求 OAM 的 API 直接获取对应的瓦片。rs download https://tiles.openaerialmap.org/5ac626e091b5310010e0d480/0/5ac626e091b5310010e0d481/{z}/{x}/{y}.png building.tiles注意: OAM 提供多个 Slippy Map 接口这两种方法都是为了实现一个目的: 生成一个包含 256x256大小的无人机影像的 Slippy Map。我们可以用提取过的建筑几何边界和瓦片文件创建对应蒙版。rs rasterize –dataset dataset-building.toml –zoom 20 –size 256 buildings.geojson buildings.tiles masks在栅格化之前我们需要创建dataset-building.toml数据集;训练与建模RoboSat 分割模型是一个全连接神经网络,为了保证模型效果,我们进行了数据集切割:80% 训练集,用于模型训练10% 验证集,用户模型效果验证10% 预留集,用户模型超参数调优我们按照上述比例对 building.tiles 文件随机洗牌到不同的 Slippy Map 文件夹下。rs subset images validation.tiles dataset/validation/imagesrs subset masks validation.tiles dataset/validation/labelsrs subset images training.tiles dataset/training/imagesrs subset masks training.tiles dataset/training/labelsrs subset images evaluation.tiles dataset/evaluation/imagesrs subset masks evaluation.tiles dataset/evaluation/labels因为前景和背景在数据集中分布不均,模型训练之前需要先计算一下每个类的分布。rs weights –dataset dataset-building.toml将权重以配置文件的形式保存起来,之后训练会用到。一切都准备好了,可以开始训练了:rs train –model model-unet.toml –dataset dataset-building.toml在训练过程中对于每个 epoch,都会将历史的训练和验证的损失以及指标保存到 checkpoint 中。我们可以在训练过程中选择在 validation 中表现最好的model存在 checkpoint 中。用保存下来的checkpoint能够预测图像中每个像素用以区分前景和背景的所属类别概率,随后这些所属类别概率可转为离散的分割蒙版。rs predict –tile_size 256 –model model-unet.toml –dataset dataset-building.toml –checkpoint checkpoint-00038-of-00050.pth images segmentation-probabilitiesrs masks segmentation-masks segmentation-probabilities这些 Slippy Map 文件夹也可以通过 HTTP 服务器的方式直接对外提供栅格瓦片服务。我们通过 rs serve 也提供按需瓦片服务,它既能有效进行后处理(瓦片边界、去噪音、矢量化,边缘简化)也可以方便地debug。如果你手工检查预测结果你需要注意,本来不存在建筑物但是模型缺圈出来的建筑物是被称为假阳性错误。假阳性错误是因为我们的数据集导致的,我们在数据集中反复采样带有建筑的例子,即使这些瓦片的背景像素不包含足够的背景(被称为负采样)也一样被用来学习勾勒建筑物轮廓。如果我们不反复输入一张图片,那么模型是很难区分出哪些是背景的。解决这个问题有两种方法:添加随机采样的背景瓦片到训练集中,重新计算类的分布权重,然后再次训练,或者使用我们训练好的模型在数据集上反复训练,然后故意在已知无建筑物的样本中进行预测。如果模型出现误判了,然后我们就调出这部分数据集,再次训练。使用所谓的 “硬负挖掘”,允许我们主动添加负样本。这个方法只在小而干净的数据集上使用。对于"硬负挖掘” 我们从不在 building.tiles 的瓦片中随机采样来训练模型。然后通过rs compare 工具创建无建筑的图像,接着进行预测。rs compare visualizations images segmentation-masks在确认真正的背景图片后,不止要在OSM上标注,而且还要找出对应的全背景的负样本到数据集中。接着,走一遍训练流程:rs weights更新配置重新训练通常,在干净的小数据集上做很多遍硬负挖掘和训练才能使得模型收敛。恭喜!你现在有一个鲁棒的模型用于预测了!在花费数小时的硬负样挖掘,我们能产出分割概率了。有趣的看到的是该模型并不完全确定建筑工地。这是因为在我们用硬样挖掘方法选择数据集时决定是否使用建筑工地决定的。最后,后处理流程是有必要的,它将分割蒙版转化为矢量化的 GeoJSON 之后用于瓦片文件生成。本文不对后处理的细节展开,因为基于小样本的训练方式比较粗糙的,RoboSat 后处理模块还在打磨中。总结本教程手把手过了一遍 RoboSat 全流程,从数据集准备、训练分割模型到预测航拍图片中的建筑物。本教程所有工具和数据都开源了。尝试一下吧! https://github.com/mapbox/rob…作为分享主义者(sharism),本人所有互联网发布的图文均遵从CC版权,转载请保留作者信息并注明作者 Harry Zhu 的 FinanceR专栏:https://segmentfault.com/blog…,如果涉及源代码请注明GitHub地址:https://github.com/harryprince。微信号: harryzhustudio商业使用请联系作者。参考资料https://www.openstreetmap.org…https://github.com/mapbox/rob… ...

February 21, 2019 · 2 min · jiezi

[原] 解密 Uber 数据团队的大规模地理数据可视化神器:Deck.gl 与 H3

背景如何大规模可视化地理数据一直都是一个业界的难点,随着2015年起 Uber 在这一领域的发力,构建了基于 Deck.gl + H3 (deckgl,h3r) 的大规模数据可视化方案。一方面,极大地满足了大规模地理数据可视化的需求。另一方面,也极大地方便了数据科学家的可视化工作。在大规模空间轨迹分析、交通流量与供需预测等领域得到广泛应用,突破了原来leaflet架构中数据量(通常不会超过10W个原始点)的瓶颈问题,实现百万点绘制无压力,并且可以结合GPU实现加速渲染。地理单元:H3随着互联网出行公司的全球化扩张,越来越多的公司涌现出对地理单元划分的需求。一方面,传统的地理单元比如 S2和geohash,在不同纬度的地区会出现地理单元单位面积差异较大的情况,这导致业务指标和模型输入的特征存在一定的分布倾斜和偏差,使用六边形地理单元可以减少指标和特征normalization的成本。另一方面,在常用的地理范围查询中,基于矩形的查询方法,存在8邻域到中心网格的距离不相等的问题,也就是说六边形网格与周围网格的距离有且仅有一个,而四边形存在两类距离,而六边形的周围邻居到中心网格的距离却是相等的,从形状上来说更加接近于圆形。所以,基于hexagon的地理单元已经成为各大厂家的首选,比如 Uber 和 Didi 的峰时定价服务。在这样的背景下 Uber 基于六边形网格的地理单元开源解决方案 H3 应运而生,它使得部署 Hexagon 方案的成本非常低,通过UDF、R pacakge等方式可以以非常低的成本大规模推广。H3 的前身其实是 DDGS(Discrete global grid systems) 中的 ISEA3H,其原理是把无限的不规则但体积相等的六棱柱从二十面体中心延伸,这样任何半径的球体都会穿过棱镜形成相等的面积cell,基于该标准使得每一个地理单元的面积大小就可以保证几乎相同。然而原生的 ISEA3H 方案在任意级别中都存在12个五边形,H3 的主要改进是通过坐标系的调整将其中的五边形都转移到水域上,这样就不影响大多数业务的开展。下面是 ISEA3H 五边形问题的示例:#Include librarieslibrary(dggridR)library(dplyr)#Construct a global grid with cells approximately 1000 miles acrossdggs <- dgconstruct(spacing=1000, metric=FALSE, resround=‘down’)#Load included test data setdata(dgquakes)#Get the corresponding grid cells for each earthquake epicenter (lat-long pair)dgquakes$cell <- dgGEO_to_SEQNUM(dggs,dgquakes$lon,dgquakes$lat)$seqnum#Converting SEQNUM to GEO gives the center coordinates of the cellscellcenters <- dgSEQNUM_to_GEO(dggs,dgquakes$cell)#Get the number of earthquakes in each cellquakecounts <- dgquakes %>% group_by(cell) %>% summarise(count=n())#Get the grid cell boundaries for cells which had quakesgrid <- dgcellstogrid(dggs,quakecounts$cell,frame=TRUE,wrapcells=TRUE)#Update the grid cells’ properties to include the number of earthquakes#in each cellgrid <- merge(grid,quakecounts,by.x=“cell”,by.y=“cell”)#Make adjustments so the output is more visually interestinggrid$count <- log(grid$count)cutoff <- quantile(grid$count,0.9)grid <- grid %>% mutate(count=ifelse(count>cutoff,cutoff,count))#Get polygons for each country of the worldcountries <- map_data(“world”)#Plot everything on a flat mapp<- ggplot() + geom_polygon(data=countries, aes(x=long, y=lat, group=group), fill=NA, color=“black”) + geom_polygon(data=grid, aes(x=long, y=lat, group=group, fill=count), alpha=0.4) + geom_path (data=grid, aes(x=long, y=lat, group=group), alpha=0.4, color=“white”) + geom_point (aes(x=cellcenters$lon_deg, y=cellcenters$lat_deg)) + scale_fill_gradient(low=“blue”, high=“red”)p转化坐标系后:#Replot on a spherical projectionp+coord_map(“ortho”, orientation = c(-38.49831, -179.9223, 0))+ xlab(’’)+ylab(’’)+ theme(axis.ticks.x=element_blank())+ theme(axis.ticks.y=element_blank())+ theme(axis.text.x=element_blank())+ theme(axis.text.y=element_blank())+ ggtitle(‘Your data could look like this’)H3 中还提供了类似 S2 的六边形压缩技术,使得数据的存储空间可以极大压缩,在处理大规模稀疏数据时将体现出优势:地理数据可视化:Deck.gl在使用 Deck.gl 之前,业界通用的解决方案通常是另一个开源的轻量级地理数据可视化框架 Leaflet。Leaflet 经过十余年的积累已经拥有足够成熟的生态,支持各式各样的插件扩展。不过随着 Leaflet 也暴露出一些新的问题,比如如何大规模渲染地理数据,支持诸如 轨迹、风向、六边形网格的可视化。好在近年来 Mapbox 和 Deck.gl 正在着手改变这一现状。下面是一个具体的例子,如何可视化Hexagon:# 初始化devtools::install_github(“crazycapivara/deckgl”)library(deckgl)# 设置 Mapbox token,过期需要免费在 Mapbox 官网申请Sys.setenv(MAPBOX_API_TOKEN = “pk.eyJ1IjoidWJlcmRhdGEiLCJhIjoiY2poczJzeGt2MGl1bTNkcm1lcXVqMXRpMyJ9.9o2DrYg8C8UWmprj-tcVpQ”)# 数据集合sample_data <- paste0( “https://raw.githubusercontent.com/", “uber-common/deck.gl-data/”, “master/website/sf-bike-parking.json”)properties <- list( pickable = TRUE, extruded = TRUE, cellSize = 200, elevationScale = 4, getPosition = JS(“data => data.COORDINATES”), getTooltip = JS(“object => object.count”))# 可视化deckgl(zoom = 11, pitch = 45) %>% add_hexagon_layer(data = sample_data, properties = properties) %>% add_mapbox_basemap(style = “mapbox://styles/mapbox/light-v9”) 除了六边形之外 Deck.gl 也支持其他常见几何图形,比如 Grid、Arc、Contour、Polygon 等等。更多信息可以见官方文档: https://crazycapivara.github….地理仪表盘:结合 ShinyDeck.gl 结合 Shiny 后,可将可视化结果输出到仪表盘上:library(mapdeck)library(shiny)library(shinydashboard)library(jsonlite)ui <- dashboardPage( dashboardHeader() , dashboardSidebar() , dashboardBody( mapdeckOutput( outputId = ‘myMap’ ), sliderInput( inputId = “longitudes” , label = “Longitudes” , min = -180 , max = 180 , value = c(-90, 90) ) , verbatimTextOutput( outputId = “observed_click” ) ))server <- function(input, output) { set_token(‘pk.eyJ1IjoidWJlcmRhdGEiLCJhIjoiY2poczJzeGt2MGl1bTNkcm1lcXVqMXRpMyJ9.9o2DrYg8C8UWmprj-tcVpQ’) ## 如果token 过期了,需要去Mapbox官网免费申请一个 origin <- capitals[capitals$country == “Australia”, ] destination <- capitals[capitals$country != “Australia”, ] origin$key <- 1L destination$key <- 1L df <- merge(origin, destination, by = ‘key’, all = T) output$myMap <- renderMapdeck({ mapdeck(style = mapdeck_style(‘dark’)) }) ## plot points & lines according to the selected longitudes df_reactive <- reactive({ if(is.null(input$longitudes)) return(NULL) lons <- input$longitudes return( df[df$lon.y >= lons[1] & df$lon.y <= lons[2], ] ) }) observeEvent({input$longitudes}, { if(is.null(input$longitudes)) return() mapdeck_update(map_id = ‘myMap’) %>% add_scatterplot( data = df_reactive() , lon = “lon.y” , lat = “lat.y” , fill_colour = “country.y” , radius = 100000 , layer_id = “myScatterLayer” ) %>% add_arc( data = df_reactive() , origin = c(“lon.x”, “lat.x”) , destination = c(“lon.y”, “lat.y”) , layer_id = “myArcLayer” , stroke_width = 4 ) }) ## observe clicking on a line and return the text observeEvent(input$myMap_arc_click, { event <- input$myMap_arc_click output$observed_click <- renderText({ jsonlite::prettify( event ) }) })}shinyApp(ui, server)参考资料Uber H3 原理分析http://strimas.com/spatial/he…https://cran.r-project.org/we...https://en.wikipedia.org/wiki...http://www.pyxisinnovation.co...Large Scale Data Visualisation with Deck.gl and Shinyhttps://uber.github.io/h3/https://eng.uber.com/shan-he/https://eng.uber.com/keplergl/[译] 解密 Uber 数据部门的数据可视化最佳实践作为分享主义者(sharism),本人所有互联网发布的图文均遵从CC版权,转载请保留作者信息并注明作者 Harry Zhu 的 FinanceR专栏:https://segmentfault.com/blog…,如果涉及源代码请注明GitHub地址:https://github.com/harryprince。微信号: harryzhustudio商业使用请联系作者。 ...

October 22, 2018 · 3 min · jiezi