In [ ]:
from math import pow, cos, pi, radians, degrees, atan, tan, sinh, log

from ipywidgets import Image, IntSlider

from ipyevents import Event

from ipycanvas import Canvas, MultiCanvas, hold_canvas
In [ ]:
import asyncio

class Timer:
    def __init__(self, timeout, callback):
        self._timeout = timeout
        self._callback = callback
        self._task = asyncio.ensure_future(self._job())

    async def _job(self):
        await asyncio.sleep(self._timeout)
        self._callback()

    def cancel(self):
        self._task.cancel()

def debounce(wait):
    """ Decorator that will postpone a function's
        execution until after `wait` seconds
        have elapsed since the last time it was invoked. """
    def decorator(fn):
        timer = None
        def debounced(*args, **kwargs):
            nonlocal timer
            def call_it():
                fn(*args, **kwargs)
            if timer is not None:
                timer.cancel()
            timer = Timer(wait, call_it)
        return debounced
    return decorator
In [ ]:
def numTiles(z):
    return pow(2, z)

def sec(x):
    return 1 / cos(x)

def latlon2relativeXY(lat, lon):
    x = (lon + 180) / 360
    y = (1 - log(tan(radians(lat)) + sec(radians(lat))) / pi) / 2
    return x,y

def latlon2xy(lat, lon, z):
    n = numTiles(z)
    x, y = latlon2relativeXY(lat, lon)
    return n * x, n * y
  
def tileXY(lat, lon, z):
    x, y = latlon2xy(lat, lon, z)
    return int(x), int(y)

def xy2latlon(x, y, z):
    n = numTiles(z)
    relY = y / n
    lat = mercatorToLat(pi * (1 - 2 * relY))
    lon = -180 + 360 * x / n
    return lat, lon

def mercatorToLat(mercatorY):
    return degrees(atan(sinh(mercatorY)))
In [ ]:
def get_tile_grid(x, y, width, height, ntiles):
    def get_indices(x, width, ntiles):
        def _(x, width, p, ntiles, xs=None, ns=None):
            '''p == 0: backward
            p == 1: forward
            Must be called with p=0 then p=1
            '''
            x2 = (x % 1) * 256
            if p == 0:
                xs = [width / 2 - x2]
                ns = [int(x)]
            else:
                x2 = 256 - x2
            done = False
            while not done:
                if x2 >= width / 2:
                    # out of canvas, don't show next tile
                    done = True
                else:
                    # show (part of) next tile
                    x2 += 256
                    if p == 0:
                        n1 = ns[-1] - 1
                        x1 = xs[-1] - 256
                        if n1 < 0:
                            done = True
                        else:
                            ns.append(n1)
                            xs.append(x1)
                    else:
                        n1 = ns[-1] + 1
                        x1 = xs[-1] + 256
                        if n1 >= ntiles:
                            done = True
                        else:
                            ns.append(n1)
                            xs.append(x1)
            if p == 0:
                xs = xs[::-1]
                ns = ns[::-1]
            return xs, ns
        xs, ns = _(x, width, 0, ntiles)
        xs, ns = _(x, width, 1, ntiles, xs, ns)
        return xs, ns
    xs, xn = get_indices(x, width, ntiles)
    ys, yn = get_indices(y, height, ntiles)
    def get_grid(xs, ys):
        xys = []
        for j in ys:
            xys.append([])
            for i in xs:
                xys[-1].append((i, j))
        return xys
    xys = get_grid(xs, ys)
    xyn = get_grid(xn, yn)
    return {'pix': xys, 'tile': xyn}
In [ ]:
class Map(MultiCanvas):
    def __init__(self, width=256, height=256, zoom=0, lat=0, lon=0):
        super(Map, self).__init__(2, width=width, height=height)
        
        self.z = zoom
        self.tile_nb = numTiles(zoom)
        self.height = height
        self.width = width
        self.dragging = False
        self.lat = lat
        self.lon = lon
        self.x, self.y = latlon2xy(lat, lon, zoom)
        
        self.show(lat, lon)
        
        self[1].on_mouse_down(self.mouse_down_handler)
        self[1].on_mouse_move(self.mouse_move_handler)
        self[1].on_mouse_up(self.mouse_up_handler)
        self[1].on_mouse_out(self.mouse_out_handler)

    def show(self, lat=None, lon=None):
        if lat is None:
            lat = self.lat
        if lon is None:
            lon = self.lon
        x, y = latlon2xy(lat, lon, self.z)
        grid = get_tile_grid(x, y, self.width, self.height, self.tile_nb)
        
        with hold_canvas(self):
            self[0].fill_style = '#aad3df'
            self[0].fill_rect(0, 0, self.width, self.height)

            for i, row in enumerate(grid['pix']):
                for j, col in enumerate(row):
                    dx, dy = col
                    x, y = grid['tile'][i][j]
                    url = f'https://tile.openstreetmap.org/{self.z}/{x}/{y}.png'
                    tile = Image.from_url(url)
                    self[0].draw_image(tile, dx, dy)

    def mouse_down_handler(self, pixel_x, pixel_y):
        self.dragging = True
        self.x_mouse = pixel_x
        self.y_mouse = pixel_y
    
    @debounce(0.01)
    def mouse_move_handler(self, pixel_x, pixel_y):
        if self.dragging:
            x, y, lat, lon = self.delta(pixel_x, pixel_y)
            self.show(lat, lon)
    
    def delta(self, pixel_x, pixel_y):
        dx = (pixel_x - self.x_mouse) / 256
        dy = (pixel_y - self.y_mouse) / 256
        x = self.x - dx
        y = self.y - dy
        lat, lon = xy2latlon(x, y, self.z)
        return x, y, lat, lon
    
    def mouse_up_handler(self, pixel_x, pixel_y):
        self.dragging = False
        self.x, self.y, self.lat, self.lon = self.delta(pixel_x, pixel_y)
        self.show()
    
    def mouse_out_handler(self, pixel_x, pixel_y):
        self.dragging = False
In [ ]:
m = Map(788, 788, 7, 49, 2)
m
In [ ]: