There's more than one way to draw a map — lessons learned after the Google Maps SDK became unaffordable



Alexander Gaevsky, Basil Dubyk

Map service architecture

Spatial services:

  • OpenStreetMap
  • MapBox
  • TomTom
  • HERE Maps
  • LocationIQ
  • GraphHopper
  • Jawg Maps
  • AND
  • others

Criteria:

  • Reasonable pricing, sufficient free tier.
  • Map widget
    • Markers
    • Informational modal for marker
    • Markers clustering
  • Forward geocoding
  • Reverse geocoding
  • Location autocomplete (start typing and service suggests
    full address: for instance, city and country)
  • Available Python SDK is a plus.

Extra criteria:

  • The most accurate spatial data for Germany and Austria locations preliminary and German internationalization
  • Markers performance — needed to build cluster from 10k markers

OpenStreetMaps (OSM)
openstreetmap.org

Pricing:

Free

Features:

  • SDK
    • Mobile SDK
    • Python SDK
    • JS Maps SDK
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Autocomplete — no dedicated endpoint
  • Dataset — own.
  • Localization

TomTom
tomtom.com

Pricing:

  • Free — 2500 requests per day (75000 requests per day), 5 requests per second for non-tile based APIs and 1,000 for tile based APIs
  • Pay-as-you-grow
    • 50k requests — $25 / 25 €
    • 100k requests — $49 / 49 €
    • 250k requests — $119 / 119 €
    • 500k requests — $229 / 229 €
    • 1m requests — $449 / 449 €

Features:

  • SDK
    • Maps SDK for Android
    • Maps SDK for iOS
    • Maps SDK for Web (JS)
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Search
    • Autocomplete — dedicated endpoint
  • Localization
  • Dataset — own

TomTom
tomtom.com

One API request equals one transaction for all TomTom Maps APIs, except in the case of:

  • Maps API and Traffic API tiles, for which 15 requests equal to 1 transaction.
  • EV Charging Stations Availability (part of the Extended Search API), where 1 request equals to 10 transactions.
  • Long Distance EV Routing (part of Extended Routing API), where 1 request equals to 10 transactions.
  • Batch search, for which the number of transactions equals the number of individual search requests.
  • Batch routing, for which the number of transactions equals the number of individual routing requests.
  • Matrix routing, for which the number of transactions equals the size of a matrix.
  • Send Position feature in Location History API, where 5 requests equal to 1 transaction.
  • Points of Interest Details (part of the Extended Search API), where 1 request equals to 10 transactions.
  • Points of Interest Photos (part of the Extended Search API), where 1 request equals to 5 transactions.

MapBox
mapbox.com

Pricing:

  • Maps for web:
    • Free — 50000 requests
    • 50,001 to 100,000 — $5.00 / 1k requests
    • 100,001 to 200,000 — $4.00 / 1k requests
    • 200,001 to 1,000,000 — $3.00 / 1k request
  • API:
    • Temporary Geocoding API
      • Free — 100,000
      • 100,001 to 500,000 — $0.75 / 1k requests
      • 500,001 to 1,000,000 — $0.60 / 1k requests
      • 1,000,001 to 5,000,000 — $0.45 / 1k requests
    • Permanent Geocoding API
      • 1 to 500,000 — $5.00 / 1k requests
      • 500,001 to 1,000,000 — $4.00 / 1k requests

Features:

  • SDK
    • Mobile SDK (iOS, Android)
    • Python SDK — yes, but its development paused
    • AR
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Autocomplete — no dedicated endpoint
  • Localization
  • Dataset:
    • Own proprietary data
    • Open data projects (OSM, MS Open Maps, Wikidata)
    • Local data vendors (Zenrin in Japan, PSMA in Australia, and Visicom in the UAE)

HERE Maps
here.com

Pricing:

  • Freemium — Free
    • 250k transactions / month
    • 5k Mobile SDK users / month
    • 250 assets / month
    • 2.5 GB data transfer / month
    • 5 GB database storage / month
  • Add-on — 45 €
    • Same as freemium + DataHub
    • 10 GB data transfer / month
    • 10 GB database storage / month
  • Pro — 449 €
    • 1m transactions / month
    • 5k Mobile SDK users / month
    • 250 assets / month
    • DataHub
    • Live Sense SDK

Features:

  • SDK
    • Javascript Maps SDK
    • Mobile SDK
    • Python SDK — no
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Location discover
    • Brose — access to HERE Maps content
    • Location lookup by ID
    • Autocomplete — dedicated endpoint
  • Localization
  • Dataset — own

GraphHopper
graphhopper.com

Pricing:

  • Free — 500 credits/day

    For non-commercial use only. It has a limited credit count per minute, a limited set of vehicle profiles and cannot use the flexible mode (ch.disable=true).

  • Basic 48€ — 5,000 credits/day
  • Standard 128€ — 15,000 credits/day
  • Premium 304€ — 50,000 credits/day
For the Geocoding API we have integrated different providers. Each provider has its own prices. To take these differences into account, the prices per geocoding request vary depending on the provider.
The following list shows the credit costs per request for the different providers:
  • default: 0.3 credits
  • gisgraphy: 0.3 credits
  • nominatim: 0.9 credits
  • nettoolkit: 0.9 credits
  • opencagedata: 0.9 credits

Features:

  • SDK
    • Javascript client for Directions API
    • Clients for Directions API in csharp, haskell, Java, Kotlin, PHP, Python etc
    • Maps JS SDK
    • Mobile SDK
    • Python SDK
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Autocomplete — no dedicated endpoint
  • Dataset — OSM

LocationIQ
locationiq.com

Pricing:

  • Free — 10000 requests/day (2 requests/s, 60 request/min) -> 300k requests/month
    Limited commercial use (needs to add link to LocationIQ.com site in the app or on the site)
  • Developer (81 €) — 25k requests/day (15 requests/s)
  • Starter (170 €) — 60k requests / day (18 requests/s)
  • Growth (298 €) — 100k requests / day (20 requests/s)
  • Business Plus (808 €) — 1m requests / day (35 requests/s)

2 months free when billed annually.

Features:

  • SDK
    • Python SDK (also available csharp, dart, ruby, objective c, perl, kotlin, php, swift, scala, rust, java, haskell, r, go, cpp, closure)
    • Maps JS SDK (can be used with Leaflet, OpenLayers, MapBox GL)
    • Mobile SDK (can be used with Mapbox-gl Native SDK or OSMDroid)
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Location search
    • Autocomplete — dedicated endpoint
  • Localization — yes, but:
    Some of the datasets we rely on, like OpenStreetMap, tend to have results in local languages. Some others have results only in English. Specifying a language does not guarantee a response purely in that language. We will, however, do our best to favour that language if our data has it.

AND
and.com

Pricing:

  • API
    • Free — 10k requests/month
    • 175 € — 50k requests/month
    • 335 €—100k requests/month
  • MAP
    • Starter (149 €) — 500k map tile requests/month (OSM Map)
    • 335 € — 100k requests/month (OSM Map, AND Map, Satellite images of Netherlands and Flanders)

Features

  • Dedicated map tile server
  • SDK
    • Mobile SDK
    • JS Maps SDK
    • Python SDK
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Autocomplete — no dedicated endpoint

Jawg Maps
jawg.io

Pricing:

  • Free (only for non-commercial use)
    • 50k map views
    • 10k static maps
    • 10k place search
  • Professional (250 €)
    • 100k map views
    • 50k static maps
    • 50k place search
  • Enterprise (500 €)
    • 500k map views
    • 100k static maps
    • 100k place search

Features:

  • SDK
    • Mobile SDK (can be used with MapBox GL Android or MapBox GL iOS)
    • JS Maps SDK (can be used with MapBox GL or Leaflet)
    • Python SDK
  • RESTful API
    • Forward geocoding
    • Reverse geocoding
    • Location search
    • Autocomplete — dedicated endpoint
  • Localization
  • Dataset
    • OSM
    • OpenAddresses
    • Geonames

Pricing precaution

credit ≠ transaction ≠ request

Tile Design

OSM and HERE Maps comparison on https://tools.geofabrik.de/mc/

Autocomplete on location search

In template (here `{{ place }}` rendered by Django, not by Vue):




{# Search term dropdown #}

In javascript:


const highlightingEl = { start: '', end: '' };
const autocompletionUrl = 'https://autocomplete.geocoder.api.here.com/6.2/suggest.json';

// Sample of Vue app method
fetchSearchPlaceResults() {
    let vm = this;
    let params = '?' +
        'query=' +  encodeURIComponent(vm.place) +
        '&beginHighlight=' + encodeURIComponent(highlightingEl.start) +
        '&endHighlight=' + encodeURIComponent(highlightingEl.end) +
        '&maxresults=15' +
        '&language=de' + // Show localized
        '&country=DEU,AUT' + // Search only in Germany and Austria
        '&app_id=' + hereAppId +
        '&app_code=' + hereAppCode;

    let request = new XMLHttpRequest();
    request.open('GET', autocompletionUrl + params );
    request.onload = function(e) {
        let data = JSON.parse(request.response);
        if (data.suggestions) {
            vm.searchPlaceResults = data.suggestions;
            vm.showSearchPlaceDropdown(); // Triggers dropdown
        } else {
            vm.hideSearchPlaceDropdown(); // Triggers dropdown
        }
    };
    request.send();
}

Autocomplete response example

Example of suggestions for Koln.


{
    "suggestions": [
        {
            "label": "Deutschland, Köln, Köln",
            "language": "de",
            "countryCode": "DEU",
            "locationId": "NT_BXqF2OWXjw0Df08cTCVyUB",
            "address": {
                "country": "Deutschland",
                "state": "Nordrhein-Westfalen",
                "county": "Köln",
                "city": "Köln",
                "postalCode": "50667"
            },
            "matchLevel": "city"
        },
        {
            "label": "Deutschland, Köln",
            "language": "de",
            "countryCode": "DEU",
            "locationId": "NT_40ftPMCK.PEJF3psEkvBNA",
            "address": {
                "country": "Deutschland",
                "state": "Nordrhein-Westfalen",
                "county": "Köln"
            },
            "matchLevel": "county"
        }
    ]
}

Filtering of autocomplete results

Filter results based on the page requirements:

if (data.suggestions) {
    if (document.getElementById('searchbar')) {
        vm.searchPlaceResults = data.suggestions.filter(function (item) {
            return item.matchLevel === 'city';
        });
    }
    else {
        vm.searchPlaceResults = data.suggestions;
    }
    vm.showSearchPlaceDropdown();
}

How we drawn a custom markers previously

In template:


{% block scripts %}
    
{% endblock %}

In javascript:


let markerIcon = new H.map.Icon(markerImage);

How we draw custom markers now

Now we use SVG to draw a marker.


const pixelRatio = window.devicePixelRatio || 1;
const markerSize = 40 * pixelRatio;
const markerIcon = new H.map.Icon(markerSVGTemplate, {
    size: { w: markerSize, h: markerSize },
    anchor: { x: markerSize / 2, y: markerSize / 2 },
});

Show additional info on a Mouse Click

Our custom info window

Styling of info window


.H_ib * {
    box-sizing: border-box;
}

.H_ib_body {
    box-sizing: border-box;
    width: 320px;
    background: var(--white);
    font-size: 18px;
    color: var(--outer-space);
    margin-right: 35px;
    bottom: 25px;
    box-shadow: 3px 3px 10px 0 rgba(0, 0, 0, 0.5);
}

Map customization

Terrain Map Tile

Traffic tile

Satellite Tile

Normal Day Tile

Setting map style in javascript


// Map initialization
let platform = new H.service.Platform({
    'app_id': hereAppId,
    'app_code': hereAppCode,
    'useHTTPS': true,
});
let defaultLayers = platform.createDefaultLayers();
let map = new H.Map(
    document.getElementById(mapElementId),
    defaultLayers.normal.map,
    mapOptions,
);

// Setting map style
let mapTileService = platform.getMapTileService({
    type: 'aerial',
});
let mapLayer = mapTileService.createTileLayer(
    'maptile',
    'terrain.day',
    pixelRatio === 1 ? 256 : 512,
    'png8',
    {
        lg: 'GER',
        ppi: pixelRatio === 1 ? undefined : 320,
    },
);
map.setBaseLayer(mapLayer);

Strategies of clustering provider


let strategy = H.clustering.Provider.Strategy.GRID;
// Use FASTGRID strategy only for pages with search results
if (document.getElementById("results")) {
    strategy = H.clustering.Provider.Strategy.FASTGRID;
}
let clusteredDataProvider = new H.clustering.Provider(dataPoints, {
    clusteringOptions: {
        strategy: strategy,
        eps: 30,
        minWeight: 2
    },
});

Strategies comparison

For 6000+ points with FASTGRID markers/clusters appear on the initialized map much faster.

GRID

FASTGRID

Spiderfier

All are closed

One is open

Spiderfier source code on GitHub | Screenshots were made based on this demo

Info windows

For single marker

For cluster (with arrows to change)

Using custom cluster icon

Cluster SVG template:


const clusterSVGTemplate = `
    
        
            
            
            
        
    
    
    
    
        {weight}
    
`;

Preparing cluster icon for rendering:


// Sample of Vue app method
getClusterIcon(clusterWeight) {
    let svgString = clusterSVGTemplate.replace(/{weight}/g, clusterWeight);
    return new H.map.Icon(svgString, {
        size: {w: markerSize, h: markerSize},
        anchor: {x: markerSize / 2, y: markerSize / 2}
    });
},

HERE REST API Client

import urllib

from django.conf import settings
from django.core.cache import cache

import requests


HERE_GEOCODE_API_URL = 'https://geocode.search.hereapi.com/v1/geocode'
HERE_REVERSE_GEOCODE_API_URL = 'https://revgeocode.search.hereapi.com/v1/revgeocode'
HERE_LOCATION_API_URL = 'https://lookup.search.hereapi.com/v1/lookup'


class HereClient(object):
    def _get_url_params(self, **extra_params):
        params = {'apiKey': settings.HERE_API_KEY, 'lang': settings.HERE_LANGUAGE}
        params.update(**extra_params)
        return urllib.parse.urlencode(params)

    def make_request(self, url, **params):
        url = '%s?%s' % (url, self._get_url_params(**params))
        response = requests.get(url)
        if response.status_code == 200:
            data = response.json()
            return data

    def geocode(self, query):
        data = self.make_request(HERE_GEOCODE_API_URL, q=query)
        if data:
            return data['items']

    def reverse_geocode(self, latitude, longitude):
        params = {'limit': 1, 'at': '%s,%s' % (latitude, longitude)}
        data = self.make_request(HERE_REVERSE_GEOCODE_API_URL, **params)
        if data:
            return data['items']

    def lookup(self, location_id):
        cached_record = cache.get(location_id, None)
        if not cached_record:
            data = self.make_request(HERE_LOCATION_API_URL, id=location_id)
            cache.set(location_id, data)
            return cached_record
        return cached_record

Spatial Search — PostgreSQL

Model:

from django.contrib.gis.db import models


class Place(models.Model):
    name = models.CharField(max_length=255)
    point = models.PointField()

    def __str__(self):
        return self.name

    @property
    def lat(self):
        return self.point[1]

    @property
    def lng(self):
        return self.point[0]

API view:

from django.contrib.gis.geos import Point
from rest_framework import generics, serializers

from apps.places.models import Place


class PlaceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Place
        fields = ('name', 'lat', 'lng')


class PlaceListAPIView(generics.ListAPIView):
    queryset = Place.objects.all()
    serializer_class = PlaceSerializer

    def filter_queryset(self, queryset):
        query = self.request.query_params.get('q', None)
        latitude = self.request.query_params.get('lat', None)
        longitude = self.request.query_params.get('lng', None)
        radius = self.request.query_params.get('r', 15) or 15

        if query:
            queryset = queryset.filter(name__search=query)

        if latitude and longitude:
            longitude, latitude = map(float, (longitude, latitude))
            geopoint = Point(longitude, latitude)
            queryset = queryset.filter(
                point__dwithin=(geopoint, radius)
            )

        return queryset

Spatial Search — Elastic and Django-Haystack

Haystack search index definition:

from haystack import indexes
from apps.places.models import Place


class PlaceIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True, model_attr='name')
    name = indexes.CharField(model_attr='name')
    point = indexes.LocationField(model_attr='point')
    lat = indexes.FloatField(model_attr='lat')
    lng = indexes.FloatField(model_attr='lng')

    def get_model(self):
        return Place

API view:

from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D

from haystack.query import SearchQuerySet

from rest_framework import generics, serializers


class SearchRecordSerializer(serializers.Serializer):
    name = serializers.CharField()
    lat = serializers.FloatField()
    lng = serializers.FloatField()


class SearchResultsAPIView(generics.ListAPIView):
    queryset = SearchQuerySet()
    serializer_class = PlaceSerializer

    def filter_queryset(self, queryset):
        query = self.request.query_params.get('q', None)
        latitude = self.request.query_params.get('lat', None)
        longitude = self.request.query_params.get('lng', None)
        radius = self.request.query_params.get('radius', 15) or 15

        if query:
            queryset = queryset.filter(content=query)

        if latitude and longitude:
            longitude, latitude = map(float, (longitude, latitude))
            geopoint = Point(longitude, latitude)
            max_distance = D(km=radius)
            queryset = queryset.dwithin(
                'location', geopoint, max_distance,
            )

        return queryset

Questions?

Alexander Gaevsky


@agaevsky

@sasha0






metaclass.co

info@metaclass.co

metaclass.co/djangocon_europe_2020_slides