Leaflet - создание карты путешествий

12 Dec 2018

Всем привет! Сегодня будем делать карту путешествий с помощью leaflet - показывать на карте много gpx треков. Примеры из статьи доступны на github: https://github.com/antonshell/leaflet_examples.

Простое, но медленное решение

Задача сама по себе не очень сложная. В прошлом примере мы получали через ajax и отображали на карте один gpx трек. Впринципе можно сделать все то же самое в цикле.

<div id="map"></div>
 
<script type="text/javascript">
 
    $(document).ready(function() {
        createMap('map');
    });
 
    function createMap(elementId){
        var gpxArray = [
            'gpx/austria-2016/full.gpx',
            'gpx/greece-2018/full.gpx',
            'gpx/italy-coast-to-coast-2017/full.gpx',
            'gpx/italy-dolomity-2017/full-2016.gpx',
            'gpx/italy-dolomity-2017/full-2017.gpx',
            'gpx/italy-dolomity-2017/laguna.gpx',
            'gpx/sardegna-2017/full.gpx'
        ];
 
        var neLat = '36.79923084564507';
        var neLng = '8.3107243851';
        var swLat = '47.807752154767513';
        var swLng = '24.058144837617874';
 
        var map = new L.Map(elementId);
 
        var service = new L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token=pk.eyJ1IjoiYW50b25zaGVsbCIsImEiOiJjam41b3gzZmMwM3V5M2tueHpoanNocnZtIn0.LNyZF8tLB9G-JdW4svni1Q', {
            attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery � <a href="http://mapbox.com">Mapbox</a>',
            maxZoom: 18,
            id: 'mapbox.streets'
        });
 
        var bounds = new L.LatLngBounds(new L.LatLng(neLat, neLng), new L.LatLng(swLat, swLng));
        map.addLayer(service).fitBounds(bounds);
 
        for(var index in gpxArray){
            var gpx = gpxArray[index];
            $.ajax({
                url: gpx,
                dataType: "xml",
                success: function(xml) {
                    if(typeof xml != 'object'){
                        xml = $.parseXML( xml );
                    }
 
                    geojson = toGeoJSON.gpx(xml);
                    var gjl = L.geoJson(geojson).addTo(map);
                }
            });
        }
    }
</script>

Карта отображается. Треки появляются на ней, но не сразу. Треки по очереди загружаются через ajax. Каждый трек может весить несколько мб. В итоге карта может открываться довольно долго и съесть много трафика, в зависимости от количества и размера треков. Также на лету прямо в браузере происходит преобразование gpx в json, что тоже отнимает ресурсы.

Live Demo: http://demo.antonshell.me/leaflet_examples/example_02.html

Оптимизация размеров gpx треков

Это первое, что можно сделать. Если открыть .gpx файл в блокноте, то мы увидим там обычный xml. Иногда можно встретить пустые сегменты.

<trkpt lon="29.599016346" lat="60.2472433727">
  <ele>23</ele>
  <time>2015-07-17T21:09:24Z</time>
  <extensions>
    <gpxdata:hr></gpxdata:hr>
    <gpxdata:cadence></gpxdata:cadence>
    <gpxdata:temp></gpxdata:temp>
    <power></power>
  </extensions>
</trkpt>

Можно их удалить. Это позволит существенно уменьшить размер трека.

<trkpt lon="29.599016346" lat="60.2472433727">
  <ele>23</ele>
  <time>2015-07-17T21:09:24Z</time>
</trkpt>

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

Также можно уменьшить точность координат. Так, по умолчанию gpx трек содержит координаты с 10 знаками после запятой. При этом, 5 знаков после запятой дают погрешность ~1 метр. И скорее всего этого будет достаточно для отображения на карте.

decimal
places   degrees          distance
-------  -------          --------
0        1                111  km
1        0.1              11.1 km
2        0.01             1.11 km
3        0.001            111  m
4        0.0001           11.1 m
5        0.00001          1.11 m
6        0.000001         11.1 cm
7        0.0000001        1.11 cm
8        0.00000001       1.11 mm
9        0.000000001      111  μm
10       0.0000000001     11.1 μm
11       0.00000000001    1.11 μm
12       0.000000000001   111  nm
13       0.0000000000001  11.1 nm

Преобразование в geoJson

Для отображения на карте Leaflet gpx трек преобразуется в geoJson формат. В предыдущих примерых это происходило на лету, прямо в браузере. Это занимает время и ресурсы клиента. Чтобы ускорить отображение gpx и разгрузить компьютеры пользователей, можно генерировать geoJson на сервере. Для этого можно воспользоваться одной из библиотек. Или же написать свою реализацию и включить туда оптимизации из предыдущих абзацев.

Примеры gpx треков: https://github.com/antonshell/leaflet_examples/tree/master/gpx

<?php
 
use src\GeoJson;
 
require '_bootstrap.php';
 
$geoJsonService = new GeoJson();
 
$gpxArray = [
    __DIR__ . '/gpx/austria-2016/full.gpx',
    __DIR__ . '/gpx/greece-2018/full.gpx',
    __DIR__ . '/gpx/italy-coast-to-coast-2017/full.gpx',
    __DIR__ . '/gpx/italy-dolomity-2017/full-2016.gpx',
    __DIR__ . '/gpx/italy-dolomity-2017/full-2017.gpx',
    __DIR__ . '/gpx/italy-dolomity-2017/laguna.gpx',
    __DIR__ . '/gpx/sardegna-2017/full.gpx'
];
 
foreach ($gpxArray as $gpx){
    echo "Process gpx: " . $gpx . "\n";
 
    $geoJson = $geoJsonService->convertGpxToGeoJson($gpx, true, true, true);
    $geoJson = json_encode($geoJson, JSON_UNESCAPED_UNICODE);
    $geoJsonGz = gzencode($geoJson);
 
    $dirName = basename(dirname($gpx));
    $baseName = basename($gpx);
    $fileName = str_replace('.gpx','.geoJson',$baseName);
    $fileNameGz = str_replace('.gpx','.geoJson.gz',$baseName);
 
    $dirPath = __DIR__ . '/output/geojson/' . $dirName;
    if(!is_dir($dirPath)){
        mkdir($dirPath);
    }
 
    file_put_contents($dirPath . '/' . $fileName, $geoJson);
    file_put_contents($dirPath . '/' . $fileNameGz, $geoJsonGz);
}
 
echo "Job is done\n";
<?php
 
namespace src;
 
/**
 * Class GeoJson
 * @package src
 */
class GeoJson{
 
    const COORDINATE_PRECISION = 5;
 
    /**
     * @param $gpx
     * @param bool $reduceCoordinate
     * @return array
     */
    public function convertGpxToGeoJson($gpx, $reduceCoordinate = false, $removeTime = false, $removeElevation = false)
    {
        $geoJson = $this->getGeoJsonTemplate();
 
        $coordinates = [];
        $coordTimes = [];
 
        $parsedGpx = $this->parseGpx($gpx);
        $trkSegments = $parsedGpx['trk']['trkseg'];
 
        // alternative structure
        if(isset($trkSegments['trkpt'])){
            $trkSegments = [$trkSegments];
        }
 
        foreach ($trkSegments as $segmentKey => $segment){
            $coordTimes[$segmentKey] = [];
            $coordinates[$segmentKey] = [];
 
            if(!isset($segment['trkpt'])){
                continue;
            }
 
            $trackPoints = $segment['trkpt'];
            foreach ($trackPoints as $trackPointKey => $trackPoint){
                $elevation = $trackPoint['ele'] ?? '';
                $elevation = (string)intval($elevation);
 
                $lat = $trackPoint['@attributes']['lat'];
                $lon = $trackPoint['@attributes']['lon'];
                $time = $trackPoint['time'] ?? '';
 
                if($reduceCoordinate){
                    $lat = $this->reduceCoordinate($lat);
                    $lon = $this->reduceCoordinate($lon);
                }
 
                $coordTimes[$segmentKey][$trackPointKey] = $time;
 
                $geoJsonPoint = [
                    0 => $lon,
                    1 => $lat,
                    2 => $elevation,
                ];
 
                if($removeElevation){
                    unset($geoJsonPoint[2]);
                }
 
                $coordinates[$segmentKey][$trackPointKey] = $geoJsonPoint;
            }
        }
 
        $geoJson['features'][0]['geometry']['coordinates'] = $coordinates;
        $geoJson['features'][0]['properties']['coordTimes'] = $coordTimes;
 
        if($removeTime){
            unset($geoJson['features'][0]['properties']['coordTimes']);
        }
 
        return $geoJson;
    }
 
    /**
     * @param $gpx
     * @return mixed
     */
    private function parseGpx($gpx){
        $content = file_get_contents($gpx);
        $xml = simplexml_load_string($content);
        $json = json_encode($xml);
        $parsedGpx = json_decode($json,TRUE);
        return $parsedGpx;
    }
 
    /**
     * @return array
     */
    private function getGeoJsonTemplate(){
        $geoJson = [
            'type' => 'FeatureCollection',
            'features' => [
                0 => [
                    'type' => 'Feature',
                    'geometry' => [
                        'type' => 'MultiLineString',
                        'coordinates' => [],
                    ],
                    'properties' => [
                        'name' => '',
                        'time' => '',
                        'coordTimes' => [],
                    ],
                ]
            ]
        ];
 
        return $geoJson;
    }
 
    /**
     * @param $coordinate
     * @return string
     */
    private function reduceCoordinate($coordinate){
        $precision = self::COORDINATE_PRECISION;
        $coordinate = round($coordinate, $precision);
        $coordinate = (string)$coordinate;
        return $coordinate;
    }
}

В результате получаем json такого вида.

{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"MultiLineString",
"coordinates":[[["22.19879","54.58533","85"],["22.19877","54.58529","84"],
["22.19876","54.58528","84"],["22.19876","54.5852","84"],
["22.19877","54.58512","84"],["22.19899","54.58504","81"],
["22.19893","54.58511","80"],["22.19883","54.58506","78"],
["22.19892","54.58512","76"],["22.19892","54.58512","77"],["22.19895","54.58513","74"]
]]},"properties":{"name":"","time":""}}]}

Запишем в файл .geoJson и будем запрашивать его с сервера вместо gpx. В результате удалось уменьшить размер с 3.9 мб до 857 кб.

Gzip сжатие

Сжатие gzip позволяет уменьшить размер еще в несколько раз.

<?php
 
$gpx = __DIR__ . '/gpx/poland-2018/full.gpx';
$geoJson = $geoJsonService->convertGpxToGeoJson($gpx, true, true, false);
$geoJson = json_encode($geoJson, JSON_UNESCAPED_UNICODE);
$geoJsonGz = gzencode($geoJson);
$dirPath = __DIR__ . '/output/geojson/poland-2018';
file_put_contents($dirPath . '/full.geoJson.gz', $geoJsonGz);
 

Чтобы браузер корректно обрабатывал gzip и автоматиески извлекал его нужно передать заголовок header('Content-Encoding: gzip'); Для этого нужно специально настроить сервер(рекомендуется). Либо можно написать простенький скрипт, который будет возвращать файл и соответствующий заголовок.

var geoJsonUrl = 'output/full.php?file=poland-2018/full.geoJson.gz';
<?php
// full.php
 
$file = $_GET['file'] ?? '';
 
$dirName = basename(dirname($file));
$baseName = basename($file);
$filePath = __DIR__ . '/geojson/' . $dirName . '/' . $baseName;
$data = file_get_contents($filePath);
 
header("Content-type: application/json");
header('Content-Encoding: gzip');
 
echo $data;

В результате получаем такую статистику для одного трека:

Gpx - 3,70M: full.gpx

Geojson + оптимизации - 837,01K: full.geoJson

Geojson + оптимизации + gzip - 153,51K: full.geoJson.gz

Конкатенация треков

Обычно gpx треки берутся с gpx навигатора. Для пешеходных и велосипедных путешествий скорее всего это будет что-то из линейки Garmin Etrex. Этот навигатор обычно пишет треки по дням. В результате, для путешествия получаем примерно такой набор треков.

Пример файлов: https://github.com/antonshell/leaflet_examples/tree/master/gpx/5-capitals

2014-08-08_20-35-01_Day.gpx
2014-08-09_10-24-16_Day.gpx
2014-08-10_09-37-39_Day.gpx
2014-08-11_00-01-24_Day.gpx
2014-08-12_09-50-25_Day.gpx
2014-08-13_00-00-39_Day.gpx
2014-08-15_08-53-16_Day.gpx
2014-08-16_13-45-55_Day.gpx
2014-08-17_08-25-25_Day.gpx
2014-08-18_17-49-08_Day.gpx
2014-08-19_14-46-34_Day.gpx
2014-08-20_09-38-14_Day.gpx
2014-08-21_12-50-30_Day.gpx
2014-08-24_08-21-12_Day.gpx
2014-08-25_08-38-40_Day.gpx
2014-08-26_11-45-51_Day.gpx
2014-08-27_09-13-32_Day.gpx

Для отображения на карте удобнее ипользовать единый трек, например такой: 5-capitals-full.gpx. Для объединения(конкатенации) треков можно использовать сторонний сервис: https://gotoes.org/strava/Combine_GPX_TCX_FIT_Files.php

Или можно написать свой скрипт, который будет делать примерно то же самое.

<?php
 
use src\GpxCombine;
 
require '_bootstrap.php';
 
$combineService = new GpxCombine();
 
$gpxArray = [
    __DIR__ . '/gpx/5-capitals/2014-08-08_20-35-01_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-09_10-24-16_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-10_09-37-39_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-11_00-01-24_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-12_09-50-25_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-13_00-00-39_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-15_08-53-16_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-16_13-45-55_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-17_08-25-25_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-18_17-49-08_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-19_14-46-34_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-20_09-38-14_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-21_12-50-30_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-24_08-21-12_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-25_08-38-40_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-26_11-45-51_Day.gpx',
    __DIR__ . '/gpx/5-capitals/2014-08-27_09-13-32_Day.gpx',
];
 
$gpxContent = $combineService->combineGpxArray($gpxArray);
file_put_contents(__DIR__ . '/output/combined.gpx', $gpxContent);
 
echo "Job is done\n";
<?php
 
namespace src;
 
/**
 * Class GpxCombine
 * @package src
 */
class GpxCombine{
    /**
     * @param $gpxArray
     * @return string
     */
    public function combineGpxArray($gpxArray)
    {
        $trkSegments = [];
        foreach ($gpxArray as $gpx) {
            $parsedGpx = $this->parseGpx($gpx);
 
            if(is_array($parsedGpx['trk']['trkseg']) && count($parsedGpx['trk']['trkseg'])){
                $trkSegments = array_merge($trkSegments, $parsedGpx['trk']['trkseg']);
            }
        }
 
        $content = '';
        foreach ($trkSegments as $i1 => $trkSegment){
            $segmentContent = '';
            $segmentContent .= "\t<trkseg>\n";
 
            foreach ($trkSegment['trkpt'] as $i2 => $trkpt){
                if(!isset($trkpt['@attributes'])){
                    continue;
                }
 
                $lat = $trkpt['@attributes']['lat'];
                $lon = $trkpt['@attributes']['lon'];
                $elevation = $trkpt['ele'];
                $time = $trkpt['time'];
 
                $trkptContent = '';
                $trkptContent .= "\t\t" . '<trkpt lat="' . $lat . '" lon="' . $lon . '">' . "\n";
                $trkptContent .= "\t\t\t" . '<ele>' . $elevation . '</ele>' . "\n";
                $trkptContent .= "\t\t\t" . '<time>' . $time . '</time>' . "\n";
                $trkptContent .= "\t\t" . '</trkpt>' . "\n";
 
                /*$trkptContent = "\t" . '<trkpt lat="' . $lat . '" lon="' . $lon . '">
                                    <ele>' . $elevation . '</ele>
                                    <time>' . $time . '</time>
                                  </trkpt>';*/
 
                $segmentContent .= $trkptContent;
            }
 
            $segmentContent .= "\t</trkseg>\n";
            $content .= $segmentContent;
        }
 
        $gpxTemplate = file_get_contents(__DIR__ . '/gpx_template.gpx');
        $gpxTemplate = str_replace('{{{content}}}', $content, $gpxTemplate);
 
        return $gpxTemplate;
    }
 
    /**
     * @param $gpx
     * @return mixed
     */
    private function parseGpx($gpx){
        $content = file_get_contents($gpx);
        $xml = simplexml_load_string($content);
        $json = json_encode($xml);
        $parsedGpx = json_decode($json,TRUE);
        return $parsedGpx;
    }
}

Привязка карты

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

<?php
 
use src\GpxBounds;
 
require '_bootstrap.php';
 
$boundsService = new GpxBounds();
 
$gpxArray = [
    __DIR__ . '/gpx/austria-2016/full.gpx',
    __DIR__ . '/gpx/greece-2018/full.gpx',
    __DIR__ . '/gpx/italy-coast-to-coast-2017/full.gpx',
    __DIR__ . '/gpx/italy-dolomity-2017/full-2016.gpx',
    __DIR__ . '/gpx/italy-dolomity-2017/full-2017.gpx',
    __DIR__ . '/gpx/italy-dolomity-2017/laguna.gpx',
    __DIR__ . '/gpx/sardegna-2017/full.gpx'
];
 
$bounds = $boundsService->getLatLngBoundsArray($gpxArray);
print_r($gpxArray);
print_r($bounds);
<?php
 
namespace src;
 
use SimpleXMLElement;
 
/**
 * Class GpxBounds
 * @package src
 */
class GpxBounds{
 
    /**
     * @param $gpx
     * @return array
     */
    public function getLatLngBounds($gpx){
        $latArray = [];
        $lonArray = [];
 
        $parsedGpx = $this->parseGpx($gpx);
        foreach($parsedGpx as $line){
            if(isset($line['attributes']['LAT'])){
                $latArray[] = $line['attributes']['LAT'];
            }
 
            if(isset($line['attributes']['LAT'])){
                $lonArray[] = $line['attributes']['LON'];
            }
        }
 
        $bounds = [
            'neLat' => min($latArray),
            'neLng' => min($lonArray),
            'swLat' => max($latArray),
            'swLng' => max($lonArray),
        ];
 
        return $bounds;
    }
 
    /**
     * @param $gpxArray
     * @return array
     */
    public function getLatLngBoundsArray($gpxArray)
    {
        $latArray = [];
        $lonArray = [];
 
        foreach ($gpxArray as $gpx){
            $parsedGpx = $this->parseGpx($gpx);
            foreach($parsedGpx as $line){
                if(isset($line['attributes']['LAT'])){
                    $latArray[] = $line['attributes']['LAT'];
                }
 
                if(isset($line['attributes']['LAT'])){
                    $lonArray[] = $line['attributes']['LON'];
                }
            }
        }
 
        $bounds = [
            'neLat' => min($latArray),
            'neLng' => min($lonArray),
            'swLat' => max($latArray),
            'swLng' => max($lonArray),
        ];
 
        return $bounds;
    }
 
    /**
     * @param $gpx
     * @return mixed
     */
    private function parseGpx($gpx){
        $content = file_get_contents($gpx);
        $parser = xml_parser_create();
        xml_parse_into_struct($parser,$content,$parsedGpx);
        return $parsedGpx;
    }
}

В результате получаем координаты привязки:

Array
(
    [neLat] => 36.79923084564507
    [neLng] => 8.3107243851
    [swLat] => 47.807752154767513
    [swLng] => 24.058144837617874
)

Итоги

В результате получилась такая карта путешествий.

На ней поместилось несколько больших треков и открывается она быстро. Примеры доступены на github: https://github.com/antonshell/leaflet_examples

Карта моих путешествий выглядит так и дополняется новыми треками: https://velocrunch.ru/gpx/general-map

На этом пока все, спасибо за внимание!