地图盒子-gl-js:根据给定的线路和俯仰角调整可见区域和方位角。

23
我正在尝试为长途徒步旅行线路(如阿巴拉契亚山脉小径或太平洋走廊小径)优化Mapbox视图。这是一个示例,我手动定位了它,显示了西班牙的Senda Pirenáica:

screen capture

给出了感兴趣的区域,视口和倾角。 我需要找到正确的中心,轴承和缩放比例。

因为map.fitBounds方法假设pitch=0和bearing=0,所以无法帮助我。

我已经进行了一些试探,并发现这似乎是最小周边矩形问题的变体,但我遇到了一些额外的复杂性:

  1. 如何考虑pitch的扭曲效应?
  2. 如何针对视口的宽高比进行优化? 请注意,使视口更窄或更宽会改变最佳解决方案的轴承:

sketch

值得一提的是,我还使用turf-js,这有助于我获取线段的凸包。


所以你正在寻求有关使用一种启发式方法计算最小边界框的建议,给定一些参数?你最好去GIS.stackexchange上问问?你的具体要求是什么?也就是说,你选择了俯仰角、屏幕区域和感兴趣的区域,然后想要计算相机目标、方位和缩放吗? - Steve Bennett
正确的 - 给定了俯仰,视口和路径; 我需要中心,方位和缩放。 (我确实检查了GIS.stackexchange; SO有更多的Mapbox活动。)谢谢! - Herb Caudill
我已经澄清了问题中给出的参数。 - Herb Caudill
我不确定这是否仍然是一个Mapbox问题 - 或许现在已经成为一个普遍的数学问题了。 - Steve Bennett
3个回答

16
这个解决方案可以以正确的方位显示路径,紫色梯形轮廓显示目标“最紧凑梯形”的结果,以展示计算结果。从顶角出发的额外线条显示了地图中心(map.center())的位置。
具体步骤如下:
1. 使用“fitbounds”技术将路径渲染到地图上,以获取“北向且俯仰角为0”的大致缩放级别。 2. 将俯仰角旋转到所需角度。 3. 从画布中抓取梯形。
结果如下:

Initial view trapezoid

接下来,我们想要围绕路径旋转这个梯形,并找到梯形与点最紧密的匹配。为了测试最紧密的匹配,更容易旋转路径而不是梯形,因此我在这里采取了这种方法。我没有在路径上实现“凸包”以最小化旋转的点数,但这是可以作为优化步骤添加的。
为了得到最紧密的匹配,第一步是将map.center()移动到视图的“后面”,使路径位于其中。这是视锥体中空间最大的地方,因此在那里操作它会更容易:

The yellow shows the adjusted view position, putting the path at the back of the view

接下来,我们测量斜梯形墙壁与路径上每个点之间的距离,保存左右两侧最近的点。然后根据这些距离在水平方向上平移视图,使路径居中于视图,并缩放视图,以消除下方绿色梯形所示的空间。

Green trapezoid shows the smallest fit

获取这个“最佳拟合度”所使用的比例尺为我们提供了路径最佳视图的排名。然而,由于我们将路径推到视图的后面来确定排名,因此这种视图可能在视觉上并不是最佳的。相反,我们现在调整视图,将路径放置在视图的垂直中心,并相应地扩大视图三角形的比例。这给我们带来了品红色“最终”视图。

Final view in magenta.

最后,这个过程会针对每一个角度进行处理,最小的刻度值决定了获胜方位,我们从那里获取相关的刻度和中心位置。

mapboxgl.accessToken = 'pk.eyJ1IjoiZm1hY2RlZSIsImEiOiJjajJlNWMxenowNXU2MzNudmkzMndwaGI3In0.ALOYWlvpYXnlcH6sCR9MJg';

var map;

var myPath = [
        [-122.48369693756104, 37.83381888486939],
        [-122.48348236083984, 37.83317489144141],
        [-122.48339653015138, 37.83270036637107],
        [-122.48356819152832, 37.832056363179625],
        [-122.48404026031496, 37.83114119107971],
        [-122.48404026031496, 37.83049717427869],
        [-122.48348236083984, 37.829920943955045],
        [-122.48356819152832, 37.82954808664175],
        [-122.48507022857666, 37.82944639795659],
        [-122.48610019683838, 37.82880236636284],
        [-122.48695850372314, 37.82931081282506],
        [-122.48700141906738, 37.83080223556934],
        [-122.48751640319824, 37.83168351665737],
        [-122.48803138732912, 37.832158048267786],
        [-122.48888969421387, 37.83297152392784],
        [-122.48987674713133, 37.83263257682617],
        [-122.49043464660643, 37.832937629287755],
        [-122.49125003814696, 37.832429207817725],
        [-122.49163627624512, 37.832564787218985],
        [-122.49223709106445, 37.83337825839438],
        [-122.49378204345702, 37.83368330777276]
    ];

var myPath2 = [
        [-122.48369693756104, 37.83381888486939],
        [-122.49378204345702, 37.83368330777276]
    ];

function addLayerToMap(name, points, color, width) {
    map.addLayer({
        "id": name,
        "type": "line",
        "source": {
            "type": "geojson",
            "data": {
                "type": "Feature",
                "properties": {},
                "geometry": {
                    "type": "LineString",
                    "coordinates": points
                }
            }
        },
        "layout": {
            "line-join": "round",
            "line-cap": "round"
        },
        "paint": {
            "line-color": color,
            "line-width": width
        }
    });
}
function Mercator2ll(mercX, mercY) { 
    var rMajor = 6378137; //Equatorial Radius, WGS84
    var shift  = Math.PI * rMajor;
    var lon    = mercX / shift * 180.0;
    var lat    = mercY / shift * 180.0;
    lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180.0)) - Math.PI / 2.0);

    return [ lon, lat ];
}

function ll2Mercator(lon, lat) {
    var rMajor = 6378137; //Equatorial Radius, WGS84
    var shift  = Math.PI * rMajor;
    var x      = lon * shift / 180;
    var y      = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180);
    y = y * shift / 180;

    return [ x, y ];
}

function convertLL2Mercator(points) {
    var m_points = [];
    for(var i=0;i<points.length;i++) {
        m_points[i] = ll2Mercator( points[i][0], points[i][1] );
    }
    return m_points;
}
function convertMercator2LL(m_points) {
    var points = [];
    for(var i=0;i<m_points.length;i++) {
        points[i] = Mercator2ll( m_points[i][0], m_points[i][1] );;
    }
    return points;
}
function pointsTranslate(points,xoff,yoff) {
    var newpoints = [];
    for(var i=0;i<points.length;i++) {
        newpoints[i] = [ points[i][0] + xoff, points[i][1] + yoff ];
    }
    return(newpoints);
}

// note [0] elements are lng [1] are lat
function getBoundingBox(arr) {
    var ne = [ arr[0][0] , arr[0][1] ]; 
    var sw = [ arr[0][0] , arr[0][1] ]; 
    for(var i=1;i<arr.length;i++) {
        if(ne[0] < arr[i][0]) ne[0] = arr[i][0];
        if(ne[1] < arr[i][1]) ne[1] = arr[i][1];
        if(sw[0] > arr[i][0]) sw[0] = arr[i][0];
        if(sw[1] > arr[i][1]) sw[1] = arr[i][1];
    }
    return( [ sw, ne ] );
}

function pointsRotate(points, cx, cy, angle){
    var radians = angle * Math.PI / 180.0;
    var cos = Math.cos(radians);
    var sin = Math.sin(radians);
    var newpoints = [];

    function rotate(x, y) {
        var nx = cx + (cos * (x - cx)) + (-sin * (y - cy));
        var ny = cy + (cos * (y - cy)) + (sin * (x - cx));
        return [nx, ny];
    }
    for(var i=0;i<points.length;i++) {
        newpoints[i] = rotate(points[i][0],points[i][1]);
    }
    return(newpoints);
}

function convertTrapezoidToPath(trap) {
    return([ 
        [trap.Tl.lng, trap.Tl.lat], [trap.Tr.lng, trap.Tr.lat], 
        [trap.Br.lng, trap.Br.lat], [trap.Bl.lng, trap.Bl.lat], 
        [trap.Tl.lng, trap.Tl.lat] ]);
}

function getViewTrapezoid() {
    var canvas = map.getCanvas();
    var trap = {};

    trap.Tl = map.unproject([0,0]);
    trap.Tr = map.unproject([canvas.offsetWidth,0]);
    trap.Br = map.unproject([canvas.offsetWidth,canvas.offsetHeight]);
    trap.Bl = map.unproject([0,canvas.offsetHeight]);

    return(trap);
}

function pointsScale(points,cx,cy, scale) {
    var newpoints = []

    for(var i=0;i<points.length;i++) {
        newpoints[i] = [ cx + (points[i][0]-cx)*scale, cy + (points[i][1]-cy)*scale ];
    }
    return(newpoints);
}

var id = 1000;
function convertMercator2LLAndDraw(m_points, color, thickness) {
    var newpoints = convertMercator2LL(m_points);
    addLayerToMap("id"+id++, newpoints, color, thickness);
}

function pointsInTrapezoid(points,yt,yb,xtl,xtr,xbl,xbr) {
    var str = "";
    var xleft = xtr;
    var xright = xtl;

    var yh = yt-yb;
    var sloperight = (xtr-xbr)/yh;
    var slopeleft = (xbl-xtl)/yh;

    var flag = true;

    var leftdiff = xtr - xtl;
    var rightdiff = xtl - xtr;

    var tmp = [ [xtl, yt], [xtr, yt], [xbr,yb], [xbl,yb], [xtl,yt] ];
//    convertMercator2LLAndDraw(tmp, '#ff0', 2);

    function pointInTrapezoid(x,y) {
        var xsloperight = xbr + sloperight * (y-yb);
        var xslopeleft = xbl - slopeleft * (y-yb);

        if((x - xsloperight) > rightdiff) {
            rightdiff = x - xsloperight;
            xright = x;
        }
        if((x - xslopeleft) < leftdiff) {
            leftdiff = x - xslopeleft;
            xleft = x;
        }

        if( (y<yb) || (y > yt) ) {
            console.log("y issue");
        }
        else if(xsloperight < x) {
            console.log("sloperight");
        }
        else if(xslopeleft > x) {
            console.log("slopeleft");
        } 
        else return(true);
        return(false);
    }

    for(var i=0;i<points.length;i++) {
        if(pointInTrapezoid(points[i][0],points[i][1])) {
            str += "1";
        }
        else {
            str += "0";
            flag = false;
        }
    }
    if(flag == false) console.log(str);

    return({ leftdiff: leftdiff, rightdiff: rightdiff });
}

var viewcnt = 0;
function calculateView(trap, points, center) {
    var bbox = getBoundingBox(points);
    var bbox_height = Math.abs(bbox[0][1] - bbox[1][1]);
    var view = {};

    // move the view trapezoid so the path is at the far edge of the view
    var viewTop = trap[0][1];
    var pointsTop = bbox[1][1];
    var yoff = -(viewTop - pointsTop); 

    var extents = pointsInTrapezoid(points,trap[0][1]+yoff,trap[3][1]+yoff,trap[0][0],trap[1][0],trap[3][0],trap[2][0]);

    // center the view trapezoid horizontally around the path
    var mid = (extents.leftdiff - extents.rightdiff) / 2;

    var trap2 = pointsTranslate(trap,extents.leftdiff-mid,yoff);

    view.cx = trap2[5][0];
    view.cy = trap2[5][1];

    var w = trap[1][0] - trap[0][0];
    var h = trap[1][1] - trap[3][1];

    // calculate the scale to fit the trapezoid to the path
    view.scale = (w-mid*2)/w;

    if(bbox_height > h*view.scale) {
        // if the path is taller than the trapezoid then we need to make it larger
        view.scale = bbox_height / h;
    }
    view.ranking = view.scale;

    var trap3 = pointsScale(trap2,(trap2[0][0]+trap2[1][0])/2,trap2[0][1],view.scale);

    w = trap3[1][0] - trap3[0][0];
    h = trap3[1][1] - trap3[3][1];
    view.cx = trap3[5][0];
    view.cy = trap3[5][1];

    // if the path is not as tall as the view then we should center it vertically for the best looking result
    // this involves both a scale and a translate
    if(h > bbox_height) {
        var space = h - bbox_height;
        var scale_mul = (h+space)/h;
        view.scale = scale_mul * view.scale;
        cy_offset = space/2;
            
        trap3 = pointsScale(trap3,view.cx,view.cy,scale_mul);      
        trap3 = pointsTranslate(trap3,0,cy_offset);
        view.cy = trap3[5][1];
    }

    return(view);
}

function thenCalculateOptimalView(path) {
    var center = map.getCenter();
    var trapezoid = getViewTrapezoid();
    var trapezoid_path = convertTrapezoidToPath(trapezoid);
    trapezoid_path[5] = [center.lng, center.lat];

    var view = {};
    //addLayerToMap("start", trapezoid_path, '#00F', 2);

    // get the mercator versions of the points so that we can use them for rotations
    var m_center = ll2Mercator(center.lng,center.lat);
    var m_path = convertLL2Mercator(path);
    var m_trapezoid_path = convertLL2Mercator(trapezoid_path);

    // try all angles to see which fits best
    for(var angle=0;angle<360;angle+=1) {
        var m_newpoints = pointsRotate(m_path, m_center[0], m_center[1], angle);
        var thisview = calculateView(m_trapezoid_path, m_newpoints, m_center);
        if(!view.hasOwnProperty('ranking') || (view.ranking > thisview.ranking)) {           
            view.scale = thisview.scale;
            view.cx = thisview.cx;
            view.cy = thisview.cy;
            view.angle = angle;
            view.ranking = thisview.ranking;
        }
    }

    // need the distance for the (cx, cy) from the current north up position
    var cx_offset = view.cx - m_center[0]; 
    var cy_offset = view.cy - m_center[1];
    var rotated_offset =  pointsRotate([[cx_offset,cy_offset]],0,0,-view.angle);

    map.flyTo({ bearing: view.angle, speed:0.00001 });

    // once bearing is set, adjust to tightest fit
    waitForMapMoveCompletion(function () {
        var center2 = map.getCenter();
        var m_center2 = ll2Mercator(center2.lng,center2.lat);
        m_center2[0] += rotated_offset[0][0];        
        m_center2[1] += rotated_offset[0][1];
        var ll_center2 = Mercator2ll(m_center2[0],m_center2[1]);
        map.easeTo({
            center:[ll_center2[0],ll_center2[1]], 
            zoom : map.getZoom() });
        console.log("bearing:"+view.angle+ " scale:"+view.scale+" center: ("+ll_center2[0]+","+ll_center2[1]+")");

        // draw the tight fitting trapezoid for reference purposes    
        var m_trapR = pointsRotate(m_trapezoid_path,m_center[0],m_center[1],-view.angle);
        var m_trapRS = pointsScale(m_trapR,m_center[0],m_center[1],view.scale);
        var m_trapRST = pointsTranslate(m_trapRS,m_center2[0]-m_center[0],m_center2[1]-m_center[1]);
        convertMercator2LLAndDraw(m_trapRST,'#f0f',4);
    });
}

function waitForMapMoveCompletion(func) {
    if(map.isMoving()) 
        setTimeout(function() { waitForMapMoveCompletion(func); },250);
    else
        func();
}

function thenSetPitch(path,pitch) {
    map.flyTo({ pitch:pitch } );
    waitForMapMoveCompletion(function() { thenCalculateOptimalView(path); })
}

function displayFittedView(path,pitch) {
    var bbox = getBoundingBox(path);
    var path_cx = (bbox[0][0]+bbox[1][0])/2;
    var path_cy = (bbox[0][1]+bbox[1][1])/2;

    // start with a 'north up' view
    map = new mapboxgl.Map({
        container: 'map',
        style: 'mapbox://styles/mapbox/streets-v9',
        center: [path_cx, path_cy],
        zoom: 12
    });

    // use the bounding box to get into the right zoom range
    map.on('load', function () {
        addLayerToMap("path",path,'#888',8);
        map.fitBounds(bbox);
        waitForMapMoveCompletion(function() { thenSetPitch(path,pitch); });
    });
}

window.onload = function(e) {
    displayFittedView(myPath,60);
}
body { margin:0; padding:0; }
#map { position:absolute; top:0; bottom:0; width:100%; }
<script src='https://api.tiles.mapbox.com/mapbox-gl-js/v0.37.0/mapbox-gl.js'></script>
<link href='https://api.tiles.mapbox.com/mapbox-gl-js/v0.37.0/mapbox-gl.css' rel='stylesheet' />
<div id='map'></div>


我不太明白梯形拟合是如何选择的,最后是否会默认选择适合的最后一种排列方式?看起来旋转180度更贴切。http://imgur.com/a/39pg8 - Oleg
2
@o.v. 在这个例子中,“最前面的”点并不像视锥体的前沿那样宽,因此比例因子对于该视图和180度翻转是相同的。在这种情况下,它返回找到的第一个点。如果你把宽度缩小到起始点和结束点比前沿更宽的地步,那么它会按你的预期翻转。 - fmacdee
啊,太好了,我知道我应该相信数学。 - Oleg
@fmacdee - 真的是非常出色的工作,回答也呈现得很漂亮。谢谢。 - Herb Caudill

2
最小的周围矩形仅适用于 pitch=0(直接向下查看)。
一种选择是继续采用最小周围矩形方法,并计算目标区域的变换-就像3D引擎所做的那样。如果您这样做,可以浏览Unity文档以更好地了解视景体机制。但我感觉这对您的问题来说可能不太合适,因为您需要从不同角度重新计算目标区域的2D渲染,这是相对昂贵的暴力方法。
另一种归一化计算的方法是将视口投影渲染到目标区域平面中。自己看看:

rough projection

然后,你所需要做的就是“仅仅”找出原始凸包能够适应该形状梯形(特别是一个凸等腰梯形,因为我们不会操作相机滚动)的最大尺寸。
这就是我有点力不从心,不知道该如何指引你进行计算的地方。不过,在这个二维空间中迭代可能的解决方案至少更便宜。
附注:还有一件事要记住,视口投影形状将根据视场(FOV)而异。
当你调整浏览器视口大小时,这会发生变化,但在mapbox-gl-js中似乎没有暴露该属性。
编辑:
经过一些思考,我认为最好的数学解法在实际中可能会感到有点“枯燥”。由于不了解使用情况,并且可能会做出一些错误的假设,我想问这些问题:
对于近似直线的路线,它是否总是被放大以使得两端位于左下角和右上角?这将接近“最佳”,但可能会变得...无聊。 您是否希望保持更多靠近视口的路径?如果大部分路径远离视口,则可能会丢失路径细节。 您是否想选择感兴趣的点进行聚焦?那些可以更靠近视口。 也许按外形分类不同类型的路线并创建平移预设会很方便?

0

希望这可以通过一些调整为您指明正确的方向。

首先,我设置了我们想要显示的两个点。

 let pointA = [-70, 43]
 let pointB = [-83, 32]

然后我找到了这两个点的中间点。我自己写了一个函数来实现这个功能,但是看起来turf也可以做到。

function middleCoord(a, b){
  let x = (a - b)/2
  return _.min([a, b]) + x
}
let center = [middleCoord(pointA[0], pointB[0]), middleCoord(pointA[1], pointB[1])]

我使用了草地承载函数,让第二个点的视角看向第一个点。

let p1 = turf.point(pointA)
let p2 = turf.point(pointB)
let points = turf.featureCollection([p1, p2])
let bearing = turf.bearing(p2, p1)

然后我调用地图并运行fitBounds函数:

var map = new mapboxgl.Map({
  container: 'map', // container id
  style: 'mapbox://styles/mapbox/outdoors-v10', //hosted style id
  center: center, // starting position
  zoom: 4, // starting zoom
  pitch: 60,
  bearing: bearing
})

map.fitBounds([pointA, pointB], {padding: 0, offset: 0})

这里有一个 CodePen: https://codepen.io/thejoshderocher/pen/BRYGXq

为了最佳利用屏幕大小来调整轴承,需要获取窗口的大小并调整轴承以充分利用可用的屏幕空间。如果是纵向移动屏幕,则此轴承完美适用。如果您使用宽视野的桌面,则需要旋转以使点 A 位于顶部角落之一。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接