Mapbox-gl-js: Отрегулировать видимую область и подшипник для данной линии, для заданной высоты тона

Я пытаюсь оптимизировать представление Mapbox для дальних пешеходных маршрутов, таких как Аппалачская тропа или тропа Тихоокеанского гребня. Вот пример, который я ориентировал вручную, показывая Senda Pirenáica в Испании:

screen capture

Дана область интересов, область просмотра и высота. Мне нужно найти правильный центр, опору и масштабирование.

Метод map.fitBounds не помогает мне здесь, потому что он принимает шаг = 0 и подшипник = 0.

Я сделал несколько попыток, и это кажется вариацией проблемы самого маленького окружающего прямоугольника, но я застрял на нескольких дополнительных осложнениях:

  • Как мне объяснить искажающий эффект подачи?
  • Как оптимизировать соотношение сторон окна просмотра? Обратите внимание, что при более узком или более широком видовом экране изменение отношения наилучшего решения:

sketch

FWIW Я также использую turf-js, который помогает мне получить выпуклый корпус для линии.

Ответы

Ответ 1

Это решение приводит к пути, отображаемому на правильном подшипнике с контуром пурпурной трапеции, показывающим целевую "самую узкую трапецию", чтобы показать результаты расчетов. Дополнительная строка, исходящая из верхнего угла, показывает, где находится значение map.center().

Подход выглядит следующим образом:

  • проведите путь к карте, используя технику "fitbounds", чтобы получить приблизительный уровень масштабирования для ситуации "на север и шаг = 0"
  • поверните высоту тона до нужного угла
  • возьмите трапецию с холста

Этот результат будет выглядеть так:

Трапеция с начальным видом

После этого мы хотим повернуть эту трапецию вокруг пути и найти самую туговую привязку трапеции к точкам. Для того, чтобы протестировать самую плотную посадку, легче повернуть путь, чем трапецию, поэтому я применил этот подход здесь. Я не реализовал "выпуклую оболочку" на пути, чтобы минимизировать количество поворотных точек, но это то, что можно добавить в качестве шага оптимизации.
Чтобы получить самую плотную посадку, первым шагом является перемещение map.center(), чтобы путь находился на "обратной стороне" представления. Здесь самое большое пространство находится в усеченном состоянии, поэтому его легко манипулировать там:

Желтый показывает отрегулированную позицию просмотра, поместив путь в конец представления

Затем мы измеряем расстояние между наклонными трапециевидными стенками и каждой точкой пути, сохраняя самые близкие точки как на левой, так и на правой сторонах. Затем мы центрируем путь в представлении, переводя его горизонтально на эти расстояния, а затем масштабируем вид, чтобы исключить это пространство с обеих сторон, как показано зеленой трапецией ниже:

Зеленая трапеция показывает наименьшее соответствие

Масштаб, используемый для получения этой "самой жесткой подгонки", дает нам наш рейтинг для того, является ли это наилучшим видом пути. Однако это представление может быть не лучшим визуальным, так как мы переместили путь в обратную сторону представления, чтобы определить ранжирование. Вместо этого мы теперь корректируем представление, чтобы поместить путь в вертикальный центр представления, и масштабируем треугольник вида соответственно. Это дает нам желаемый цветной "окончательный" вид:

Окончательный вид в пурпуре.

Наконец, этот процесс выполняется для каждой степени, а минимальное значение шкалы определяет выигрышный подшипник, и мы берем соответствующий масштаб и центральное положение оттуда.

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>

Ответ 2

Самый маленький окружающий прямоугольник будет специфическим для pitch = 0 (смотря прямо вниз).

Один из вариантов заключается в том, чтобы продолжить использование самого маленького окружающего прямоугольника и вычислить преобразование целевой области - точно так же, как это делает 3D-движок. Если это то, что вы делаете, возможно, пропустите unit docs, чтобы лучше понять механику просмотр frustum

Я считаю, что это не подходит для вашей проблемы, хотя вам придется перерасчитать 2-мерный рендеринг целевой области под разными углами, относительно дорогой грубой силой.

Другим способом нормализации расчета будет преобразование проекции видового экрана в плоскость целевой области. Посмотрите сами:

грубая проекция

Затем все, что вам нужно сделать, это "просто" определить наибольший размер, который ваш оригинальный выпуклый корпус может поместиться в трапецию этой формы (в частности, выпуклый равнобедренная трапеция, поскольку мы не манипулируем рулоном камеры).

Здесь я немного замерзаю и не знаю, где указать вам расчет. Я считаю, что хотя бы дешевле перебирать возможные решения в этом двумерном пространстве.

P.S: Еще одна вещь, которую следует иметь в виду, - это то, что форма проекции проекции будет отличаться в зависимости от FOV (поле зрения).

Это изменяется при изменении размера окна просмотра браузера, но свойство похоже, не отображается в mapbox-gl-js.

Edit:

После некоторых мыслей я считаю, что лучшее математическое решение может ощущаться в сущности немного "сухим". Если вы не воспользуетесь прецедентом и, возможно, сделаете некоторые неправильные предположения, я задал бы следующие вопросы:

  • Для маршрута, который примерно соответствует прямой линии, всегда ли он будет заправлен так, чтобы концы были внизу слева и верхнем правом углу? Это будет близко к "оптимальному", но может стать... скучным.
  • Вы хотите сохранить больше пути ближе к окну просмотра? Вы можете потерять информацию о маршруте, если большая ее часть находится далеко от окна просмотра.
  • Вы могли бы сосредоточиться на интересных моментах? Они могут быть ближе к области просмотра.

Возможно, было бы удобно классифицировать различные типы маршрутов по форме корпуса и создавать предикаты для панорамирования?

Ответ 3

Надеюсь, это может указать вам в правильном направлении с некоторой настройкой.

Сначала я установил две точки, которые мы хотим показать

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

Затем я нашел середину этих двух точек. Я сделал свою собственную функцию для этого, но похоже, что дерн может это сделать.

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})

Здесь код: https://codepen.io/thejoshderocher/pen/BRYGXq

Чтобы отрегулировать подшипник, чтобы наилучшим образом использовать размер экрана, нужно получить размер окна и отрегулировать подшипник, чтобы максимально использовать доступное пространство экрана. Если это мобильный экран в портрете, этот подшипник отлично работает. Если вы находитесь на рабочем столе с широким обзором, вам нужно будет вращаться так, чтобы точка A находилась в одном из верхних углов.