diff --git a/viewport.js b/viewport.js new file mode 100644 index 0000000..8518e57 --- /dev/null +++ b/viewport.js @@ -0,0 +1,433 @@ +//help array; +let Caches = new Array(); //cache +let downloadRecord = new Array(); +let dm = new Array();//尝试增加下载管理 downloadmanager + +let alltilecount = 0; +let downtilecount = 0; + +function ViewPort(canvasId, options) { + // allow without 'new' + if (!(this instanceof ViewPort)) return new ViewPort(canvasId, options); + this.TILESIZE = 256;//实际的底图大小 + //init + this.canvas = document.getElementById(canvasId); + this.canvas.width = this.canvas.clientWidth; + this.canvas.height = this.canvas.clientHeight; + + this.ctx = this.canvas.getContext('2d'); + this.width = this.canvas.clientWidth; + this.height = this.canvas.clientHeight; + if (!options) options = {};//options could be null or undefine + this.minZoomLevel = options.minZoomLevel ?? 0; + this.maxZoomLevel = options.maxZoomLevel ?? 21; + this.zoomLevel = options.zoomLevel ?? 0; + this.dzl = options.dzl ?? 0.25; // zoomlevel offset value + this.rtSize = calTileSize(this.TILESIZE, this.zoomLevel);//named rtSize because real time tile size + + const defaultCenterX = Math.pow(2, this.zoomLevel) * this.TILESIZE / 2; + const defaultCenterY = Math.pow(2, this.zoomLevel) * this.TILESIZE / 2; + this.xoffset = this.width / 2 - defaultCenterX; + this.yoffset = this.height / 2 - defaultCenterY; + + //如果在options中有指定地图中心点 + //options.centerLng && options.centerLat + if (options.centerLat != null && options.centerLng != null + && !isNaN(options.centerLat) && !isNaN(options.centerLng)) { + console.log('指定中心点'); + try { + ({ lat: lat, lng: lng } = this.latlngPreCheck(options.centerLat, options.centerLng)); + const my = lat2y(lat, this.zoomLevel) * this.TILESIZE; + const mx = lon2x(lng, this.zoomLevel) * this.TILESIZE; + this.xoffset = this.width / 2 - mx; + this.yoffset = this.height / 2 - my; + //console.log('check ', this.viewport2Latlng(this.width / 2, this.height / 2)); + } catch (error) { + console.log('使用默认中心点'); + } + } + + this.needtiles = new Array();//需要加载的底图 实时变化 + + //move + let moveflag = false; + let downpoing = { x: 0, y: 0 }; + this.canvas.onmousedown = (e) => { + moveflag = true; + [downpoing.x, downpoing.y] = [e.offsetX, e.offsetY]; + //坐标转换 debug + //console.log(this.zoomLevel, e.offsetX, e.offsetY); + const wgs84 = this.viewport2Latlng(e.offsetX, e.offsetY); + console.log('clicked', wgs84); + } + this.canvas.onmouseup = (e) => { + moveflag = false; + } + this.canvas.onmouseout = () => { + moveflag = false;//从元素范围外移动回来时不会继续移动,必须松开左键,按下左键后再进行移动 + } + this.canvas.onmousemove = (e) => { + if (moveflag) { + const movex = downpoing.x - e.offsetX; + const movey = downpoing.y - e.offsetY; + //实现移动逻辑 + this.xoffset -= movex; + this.yoffset -= movey; + + this.drawTiles(); + downpoing.x = e.offsetX; + downpoing.y = e.offsetY; + } + } + + //scale + this.canvas.onwheel = (e) => { + const dzl = this.dzl; + const zoomleveloffset = (e.deltaY > 0) ? 1 * dzl : -1 * dzl; + const oldz = this.zoomLevel; + this.zoomLevel = this.zoomLevel - zoomleveloffset; + if (this.zoomLevel < this.minZoomLevel) { + this.zoomLevel = this.minZoomLevel; + return; + } + + if (this.zoomLevel > this.maxZoomLevel) { + this.zoomLevel = this.maxZoomLevel; + return; + } + //console.log(this.zoomLevel, e.offsetX, e.offsetY); + this.updateViewport(e.offsetX, e.offsetY, oldz, this.zoomLevel); + this.drawTiles(); + } + + this.drawTiles(); + + // setInterval(() => { + // debug(); + // }, 1000); +} + +//扩展一下Array 用以辅助进行加载底图 +Array.prototype.remove = function (val) { + const index = this.indexOf(val); + if (index > -1) { + return this.splice(index, 1); + } + return this; +} + +ViewPort.prototype = { + latlngPreCheck(lat, lng) { + //数据超界处理 经度 -180 180 + //纬度 < 85.0511287798066 (2*Math.atan(Math.pow(Math.E,Math.PI))-Math.PI/2)/Math.PI*180 + const latlimit = 85.0511287798066;//取近似值 + const lngp = lng - Math.floor((lng + 180) / 360) * 360; + if (lat < (0 - latlimit) || lat > latlimit) { + throw "OutMapRange"; + } + return { lat: lat, lng: lngp }; + }, + + mplatPreCheck(mx, my) { + //数据超界处理, 这里是对应缩放等级的地图的大小,底图大小*底图块数 注意底图块数始终为整数level时的底图块数 + //实际只改变的底图大小,没有改变块数 + let limit = calTileSize(this.TILESIZE, this.zoomLevel) * Math.pow(2, this.zoomLevel); + if (this.zoomLevel != Math.floor(this.zoomLevel)) { + limit = calTileSize(this.TILESIZE, this.zoomLevel) * Math.pow(2, Math.floor(this.zoomLevel + 1)); + } + if (mx <= 0 || my <= 0 || mx >= limit || my >= limit) { + throw "OutMapRange"; + } + return { x: mx, y: my }; + }, + + //------------------------------------- + //这几个函数是坐标转换函数 + mplat2Viewport(mx, my) { + const vx = mx + this.xoffset; + const vy = my + this.yoffset; + return { x: vx, y: vy }; + }, + + viewport2Mplat(vx, vy) { + const mx = vx - this.xoffset; + const my = vy - this.yoffset; + return { x: mx, y: my }; + }, + + viewport2Latlng(vx, vy) { + let mplatc = this.viewport2Mplat(vx, vy); //mplat coordinates + mplatc = this.mplatPreCheck(mplatc.x, mplatc.y); + const lat = tile2lat(mplatc.y / this.TILESIZE, this.zoomLevel); + const lng = tile2long(mplatc.x / this.TILESIZE, this.zoomLevel); + return { lat: lat, lng: lng } + }, + latlng2Viewport(lat, lng) { + ({ lat: lat, lng: lng } = this.latlngPreCheck(lat, lng));//尝试使用解构赋值 + const my = lat2y(lat, this.zoomLevel) * this.TILESIZE; + const mx = lon2x(lng, this.zoomLevel) * this.TILESIZE; + const vp = this.mplat2Viewport(mx, my); + return { x: vp.x, y: vp.y }; + }, + //---------------------------------------- + + //更新偏移值 + updateViewport(scrollx, scrolly, oldz, newz) { + //计算出缩放后坐标系的偏移值 + // xv = xm + offset ; offset = xv - xm + const scrollm = this.viewport2Mplat(scrollx, scrolly); + const basescale = Math.pow(2, newz - oldz); + const newscrollm = { x: scrollm.x * basescale, y: scrollm.y * basescale }; + this.xoffset = scrollx - newscrollm.x; + this.yoffset = scrolly - newscrollm.y; + }, + + //过滤需要加载的底图 + tilesFilter(tails) { + let news = new Array(); + let max = Math.pow(2, this.zoomLevel); + if (this.zoomLevel != Math.floor(this.zoomLevel)) { + max = Math.pow(2, Math.floor(this.zoomLevel + 1)); + } + for (let i = 0; i < tails.length; i++) { + const e = tails[i]; + if (e.x >= 0 && e.y >= 0 && e.x < max && e.y < max) { + news.push(e); + } + } + return news; + }, + + + //一些绘制函数 + clearCanvas() { + this.ctx.clearRect(0, 0, this.width, this.height); + }, + drawRect(x, y, width, height) { + this.ctx.strokeStyle = "white" + this.ctx.strokeRect(x, y, width, height); + }, + drawtext(text, x, y) { + this.ctx.fillStyle = "white" + this.ctx.font = "18px serif"; + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + this.ctx.fillText(text, x, y); + }, + drawTiles() { + this.clearCanvas(); + const viewport_left_top = this.viewport2Mplat(0, 0); + const viewport_right_bottom = this.viewport2Mplat(this.width, this.height); + const mixnum = calTileNum(viewport_left_top.x, this.TILESIZE, this.zoomLevel); + const miynum = calTileNum(viewport_left_top.y, this.TILESIZE, this.zoomLevel); + const maxnum = calTileNum(viewport_right_bottom.x, this.TILESIZE, this.zoomLevel); + const maynum = calTileNum(viewport_right_bottom.y, this.TILESIZE, this.zoomLevel); + const horicount = maxnum - mixnum + 1; + const verticount = maynum - miynum + 1; + let tails = new Array(); + for (let i = 0; i < horicount; i++) { + for (let j = 0; j < verticount; j++) { + const x = mixnum + i; + const y = miynum + j; + tails.push({ x: x, y: y }); + } + } + this.needtiles = this.tilesFilter(tails);//获取当前视口要渲染的底图 + alltilecount += this.needtiles.length;//for debug + //console.log(this.needtiles); + this.needtiles.forEach(et => { + const etsize = calTileSize(this.TILESIZE, this.zoomLevel); + const tilev = this.mplat2Viewport(et.x * etsize, et.y * etsize); + this.drawRect(tilev.x, tilev.y, etsize, etsize); + let z = this.zoomLevel;//这里的几行代码的逻辑是用到了5个函数里面,后面可以想一下怎么重构 + if (this.zoomLevel != Math.floor(this.zoomLevel)) { + z = Math.floor(this.zoomLevel + 1); + } + const text = `${z}/${et.x}/${et.y}.png`; + // console.log(text); + this.drawtext(text, tilev.x + etsize / 2, tilev.y + etsize / 2); + this.drawTileImg(z, et.x, et.y); + }); + //debug(); + }, + drawTileImg(z, x, y) { + + const img = getImg(this, z, x, y); + img.then((e) => { + if (isTileShouldDownload(this, z, x, y)) { + //对e添加判断,似乎不是每一个e都是一个图片 + if (e instanceof ImageBitmap) { + const etsize = calTileSize(this.TILESIZE, this.zoomLevel); + const tilev = this.mplat2Viewport(x * etsize, y * etsize); + this.ctx.drawImage(e, tilev.x, tilev.y, etsize, etsize); + } + } + }).catch((e) => { + console.log(e); + }); + }, + +} + +//计算实时底图大小 +function calTileSize(tilesize, zoomLevel) { + let rtSize = tilesize; + if (zoomLevel != Math.floor(zoomLevel)) { + rtSize = tilesize * Math.pow(2, zoomLevel - Math.floor(zoomLevel + 1)); + } + return rtSize; +} + +//计算当前坐标值所在的底图编号 +function calTileNum(coord, tilesize, zoomLevel) { + return Math.floor(coord / calTileSize(tilesize, zoomLevel)); +} + +//=====coord about 地理坐标与墨卡托投影的相关转换方法===== +function lon2tile(lon, z) { + return (Math.floor((lon + 180) / 360 * Math.pow(2, z))); +} +function lat2tile(lat, z) { + return (Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / + 2 * Math.pow(2, z))); +} +function lon2x(lon, z) { + return ((lon + 180) / 360 * Math.pow(2, z)); +} +function lat2y(lat, z) { + return ((1 - Math.log(Math.tan(lat * Math.PI / 180) + + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / + 2 * Math.pow(2, z)); +} +function tile2long(x, z) { + return (x / Math.pow(2, z) * 360 - 180); +} +function tile2lat(y, z) { + var n = Math.PI - 2 * Math.PI * y / Math.pow(2, z); + return (180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))); +} + +//=========下载图片相关================== +//将其和viewpoint分离,方便后续修复问题和增加新的下载方式或者底图源 + +//判断一张底图需不需要继续下载,这里采用保守一点的下载策略,必须是先判定为需要加载的底图才开始下载 +//传入vp以获取相关参数 +function isTileShouldDownload(vp, z, x, y) { + let fz = vp.zoomLevel; + if (vp.zoomLevel != Math.floor(vp.zoomLevel)) { + fz = Math.floor(vp.zoomLevel + 1); + } + if (vp.needtiles.find(t => z == fz && t.x == x && t.y == y)) { + return true; + } + return false; +} + +//add autonavi +function downloadimg(z, x, y) { + return new Promise((resolve) => { + //const url = `https://tile.openstreetmap.org/${z}/${x}/${y}.png`; + const url = 'http://webrd01.is.autonavi.com/' + + 'appmaptile?lang=zh_cn&size=1&scale=1&style=8' + + `&x=${x}&y=${y}&z=${z}`; + //add downloadcheck + const obj = { z: z, x: x, y: y }; + downloadRecord.push(obj); //下载前加入记录 + let img = new Image(); + const dmobj = { img: img, z: z, x: x, y: y }; + img.onload = () => { + //console.log('downloaded' + z + '/' + x + '/' + y + '.png'); + downloadRecord.remove(obj); //下载完成后从记录中移除 + dm.remove(dmobj); + Caches.push({ img: img, z: z, x: x, y: y });//下载完成后加入缓存 + downtilecount += 1; + resolve(img); + } + img.src = url; + dm.push(dmobj);//尝试添加管理 + }); +} + +//尝试优化图片下载机制 //采用下载速度比较慢的openstreetmap 测试 +function xhrdownloadimg(z, x, y) { + return new Promise((resolve, reject) => { + const url = `https://tile.openstreetmap.org/${z}/${x}/${y}.png`; + let imgxhr = new XMLHttpRequest(); + imgxhr.open('GET', url, true); + + //add downloadcheck + const obj = { z: z, x: x, y: y }; + downloadRecord.push(obj); //下载前加入一条记录 + const dmobj = { xhr: imgxhr, z: z, x: x, y: y }; + + //imgxhr.withCredentials = true; + imgxhr.responseType = 'blob'; + imgxhr.onload = function (e) { + if (this.status === 200 && imgxhr.response) { + let blob = new Blob([imgxhr.response], { type: 'image/png' }); + let img = createImageBitmap(blob); + Caches.push({ img: img, z: z, x: x, y: y }); + downtilecount += 1; + downloadRecord.remove(obj); //下载完成后将记录移除 + dm.remove(dmobj); + resolve(img); + } else { + reject('下载似乎失败了'); + dm.remove(dmobj); + } + }; + dm.push(dmobj); //保存xhr对象 和xyz索引 + imgxhr.send(null); + }); +} + +async function getImg(vp, z, x, y) { + const found = Caches.find(e => (e.z == z && e.x == x && e.y == y));//查找缓存 + if (found == undefined) {//缓存中没有 + const obj = downloadRecord.find(e => e.z == z && e.x == x && e.y == y);//查找下载记录 + if (obj == undefined) { + //如果缓存中没有,并且没有下载记录,需先判断是否继续加载 + if (isTileShouldDownload(vp, z, x, y)) {//如果要继续加载 + //let p = await downloadimg(z, x, y); //发起下载请求并等待结果 + let p = await xhrdownloadimg(z, x, y); + return p; + } + + } else { + //如果缓存中没有,但是有下载记录,需先判断是否继续加载 + if (isTileShouldDownload(vp, z, x, y)) {//如果要继续加载 + return Promise.reject('需要等待下载完成或者重新发起请求'); + } else { + //不需要继续加载了,但是下载请求已经发起 + //不知道提前卸载掉img会不会让下载终止,不然就只能考虑xmlhttprequest了 其有abort方法可以退出下载 + //测试是不行,需转为xmlhttprequest实现 + // dm.remove(dm.find(e => (e.z == z && e.x == x && e.y == y))); + // console.log("may something work"); + + //xhr 中断请求 + const dmobj = dm.find(e => (e.z == z && e.x == x && e.y == y)); + if (dmobj) { + dmobj.xhr.abort(); + dm.remove(dmobj); + console.log("主动中断 ", { z: z, x: x, y: y }); + } + } + } + } else {//缓存中有直接返回 + //console.log('exist' + z + '/' + x + '/' + y + '.png'); + return found.img; + } +} + + + +function debug() { + console.log('=============debug================='); + console.log('alltilecount', alltilecount); + console.log('downtilecount', downtilecount); + console.log('cachecount', Caches.length); + console.log('drecord count', downloadRecord.length); + console.log('dm count', dm.length); + console.log('=============debug================='); +}