class_name MapViewer extends Control const TILE_WIDTH:float = 256.0 const TILE_HEIGHT:float = 256.0 const MIN_ZOOM:float = 0.1 const MAX_ZOOM:float = 25.0 @export var base_url:String = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png': set(v): if base_url != v: base_url = v _clean_all() queue_redraw() @export_range(1,1000,1) var max_concurrent_requests:int = 5 @export_range(100,100000,1) var max_cached_tiles:int = 200 @export_range(0,25,1) var max_zoom_level:int = 21 var _xyz:Vector3 = Vector3(0,0,2.5) var _cache:Dictionary = {} var _queue:Dictionary = {} var _error:Dictionary = {} var _dragging:bool = false var _drag_pos:Vector2 = Vector2.ZERO var _last_error_check:int = 0 var _last_cache_check:int = 0 var _rollover:bool = false var _cursor:Vector2 = Vector2.ZERO func _ready(): # on mouse enter connect('mouse_entered', func(): _rollover = true queue_redraw() ) # on mouse exit connect('mouse_exited', func(): _rollover = false queue_redraw() ) func _input(e): if Engine.is_editor_hint(): return if e is InputEventMouseButton: if e.pressed and _rollover: if e.button_index == MOUSE_BUTTON_WHEEL_DOWN: apply_zoom(0.95, get_local_mouse_position()) elif e.button_index == MOUSE_BUTTON_WHEEL_UP: apply_zoom(1.05, get_local_mouse_position()) if e.button_index == MOUSE_BUTTON_LEFT: if e.pressed and _rollover: _dragging = true var lmp = get_local_mouse_position() _drag_pos = screen_to_world(lmp.x, lmp.y) else: _dragging = false elif e is InputEventMouseMotion: _cursor = get_local_mouse_position() if _dragging: var lmp = get_local_mouse_position() var wp = screen_to_world(lmp.x, lmp.y) var diff = _drag_pos - wp _xyz.x += diff.x _xyz.y += diff.y queue_redraw() func apply_zoom(multiplier: float, pivot: Vector2): var p1 = screen_to_world(pivot.x, pivot.y) _xyz.z = max(min(_xyz.z*multiplier, MAX_ZOOM),MIN_ZOOM) var p2 = screen_to_world(pivot.x, pivot.y) _xyz.x -= p2.x-p1.x _xyz.y -= p2.y-p1.y queue_redraw() func _draw(): # clear background draw_rect(Rect2(Vector2.ZERO,size), Color('#ffffff'), true) # draw zoomlevel 0 _draw_tile(0,0,0) # draw zoomlevel 1 _draw_tile(0,0,1) _draw_tile(1,0,1) _draw_tile(0,1,1) _draw_tile(1,1,1) # draw all other zoomlevels if _xyz.z >= 1: var z = min(max_zoom_level, _xyz.z) var t1 = screen_to_tile(0, 0, z, true) var t2 = screen_to_tile(size.x, size.y, z, true) for tx in range(t1.x,t2.x+1): for ty in range(t1.y,t2.y+1): _draw_tile(tx,ty,z) # draw cursor var font = Label.new().get_theme_font('') var ll = screen_to_lonlat(_cursor.x,_cursor.y) var text = lonlat_to_dms(ll.x, ll.y) draw_string(font, _cursor+Vector2(10,12), text, HORIZONTAL_ALIGNMENT_CENTER, -1, 12, Color.RED) func _clean_all(): _queue.clear() _cache.clear() _error.clear() for c in get_children(): remove_child(c) # get the tile from queue with the newest timestamp func get_next_in_queue(): if _queue.is_empty(): return null var tile = null for idx in _queue: var t = _queue.get(idx) if not tile or t.t > tile.t: tile = t return tile # delete oldest tiles from cache func _clean_cache(): var overflow = _cache.size() - max_cached_tiles if overflow <= 0: return var list = _cache.values() list.sort_custom(func(t1,t2): return t1.t < t2.t ) for i in range(overflow): var t = list[i] _cache.erase(t.i) func _clean_errors(): var now = Time.get_unix_time_from_system() var keys = _error.keys() for idx in keys: var t = _error.get(idx) var d = now - t.t if d > 10: _error.erase(t.i) func _process(delta): var now = Time.get_unix_time_from_system() # reset tile errors if not _last_error_check or now - _last_error_check > 10: _last_error_check = now _clean_errors() # if cache is overflowing - clean it if not _last_cache_check or now - _last_cache_check > 5: _last_cache_check = now _clean_cache() # if queue contains items - and new requests can be made while not _queue.is_empty() and get_child_count() < max_concurrent_requests: var tile = get_next_in_queue() if not tile: return var req = HTTPRequest.new() req.set_meta('tile', tile) add_child(req) req.name = str(tile.i) req.request_completed.connect(_response.bind(req,tile)) req.use_threads = true _queue.erase(tile.i) if req.request(tile.url) != OK: tile.t = Time.get_unix_time_from_system() _error[tile.i] = tile remove_child(req) # result, response_code, headers, body func _response(result,code,headers,body,req,tile): remove_child(req) if code == 404: prints('File not found') tile.t = Time.get_unix_time_from_system() _error[tile.i] = tile return # get the image type from the reponse header var h = ''.join(headers) var type = '' if h.contains('image/png'): type = 'png' elif h.contains('image/jpg'): type = 'jpg' elif h.contains('image/jpeg'): type = 'jpg' elif h.contains('image/bmp'): type = 'bmp' elif h.contains('image/tga'): type = 'tga' elif h.contains('image/webp'): type = 'webp' # unrecognized image type if not type: prints('Unrecognized image type') tile.t = Time.get_unix_time_from_system() _error[tile.i] = tile return # construct image from response body var image = Image.new() var error = OK if type == 'png': error = image.load_png_from_buffer(body) elif type == 'jpg': error = image.load_jpg_from_buffer(body) elif type == 'bmp': error = image.load_bmp_from_buffer(body) elif type == 'tga': error = image.load_tga_from_buffer(body) elif type == 'webp': error = image.load_webp_from_buffer(body) if error != OK: prints('Could not load the '+type+' image') tile.t = Time.get_unix_time_from_system() _error[tile.i] = tile return # create texture from image and add it to the cache var texture = ImageTexture.create_from_image(image) tile.texture = texture _cache[tile.i] = tile # redraw the map queue_redraw() func _draw_subtile(tx:int, ty:int, tz:int, origx:int, origy:int, origz:float) -> bool: var subtile = get_tile(tx, ty, tz) if not subtile: return false var p1 = tile_to_screen(origx,origy, origz) var p2 = tile_to_screen(origx+1, origy+1, origz) var x1 = tile_to_screen(tx,ty, tz) var x2 = tile_to_screen(tx+1, ty+1, tz) var xdiff = x2.x - x1.x var xrat1 = (p1.x - x1.x) / xdiff var xrat2 = (p2.x - x1.x) / xdiff var xwidth = xrat2-xrat1 var ydiff = x2.y - x1.y var yrat1 = (p1.y - x1.y) / ydiff var yrat2 = (p2.y - x1.y) / ydiff var yheight = yrat2-yrat1 var rect = Rect2(xrat1*TILE_WIDTH, yrat1*TILE_HEIGHT,xwidth*TILE_WIDTH, yheight*TILE_HEIGHT) if subtile.texture: draw_texture_rect_region(subtile.texture, Rect2(p1, p2-p1), rect) return true return false func _draw_tile(tx:int, ty:int, z:float): var tz = floor(z) var p1 = tile_to_screen(tx,ty, z) var p2 = tile_to_screen(tx+1, ty+1, z) var p3 = p1 + (p2-p1) / 2 var tile = get_tile(tx,ty,tz) if tile: if tile.texture: draw_texture_rect(tile.texture, Rect2(p1, p2-p1), false, Color.WHITE, false) else: var zzz = tz var txx = tx var tyy = ty while zzz > 1: zzz -= 1 txx = floor(txx/2) tyy = floor(tyy/2) if _draw_subtile(txx, tyy, zzz, tx,ty,z): break ## convert lon/lat to world coords func lonlat_to_world(lon:float, lat:float) -> Vector2: var x = lon / 180.0 var latsin = sin(deg_to_rad(lat) * sign(lat)) var y = (sign(lat) * (log((1.0+latsin) / (1.0-latsin)) / 2.0)) / PI return Vector2(x,y) ## convert lon/lat to screen coords func lonlat_to_screen(lon:float, lat:float) -> Vector2: var w = lonlat_to_world(lon,lat) return world_to_screen(w.x,w.y) ## convert lon/lat to tile coords func lonlat_to_tile(lon:float, lat:float, z:float, do_floor:bool=false) -> Vector2: var w = lonlat_to_world(lon,lat) return world_to_tile(w.x, w.y, z, do_floor) ## convert world coords to lon/lat func world_to_lonlat(wx:float, wy:float) -> Vector2: var lon = wx * 180.0 var lat = rad_to_deg(atan(sinh(wy * PI))) return Vector2(lon,lat) ## convert screen coords to lon/lat func screen_to_lonlat(sx:float, sy:float) -> Vector2: var w = screen_to_world(sx,sy) return world_to_lonlat(w.x, w.y) ## convert tile coords to lon/lat func tile_to_lonlat(tx:float, ty:float, tz:float) -> Vector2: var w = tile_to_world(tx, ty, tz) return world_to_lonlat(w.x, w.y) ## convert screen coords to world coords func screen_to_world(sx:float, sy:float) -> Vector2: var n = pow(2.0, _xyz.z) var span_w = n * TILE_WIDTH var span_h = n * TILE_HEIGHT var px = sx - size.x/2 + span_w/2 var py = sy - size.y/2 + span_h/2 var xr = px / span_w var yr = py / span_h var x = (xr*2.0-1.0) + _xyz.x var y = ((-yr*2.0)+1.0) + _xyz.y return Vector2(x,y) ## convert screen coords to tile coords func screen_to_tile(sx:float, sy:float, z:float, do_floor:bool=false) -> Vector2: var world = screen_to_world(sx,sy) return world_to_tile(world.x, world.y, z, do_floor) ## convert tile coords to screen coords func tile_to_screen(tx:float, ty:float, tz:float) -> Vector2: var w = tile_to_world(tx, ty, tz) return world_to_screen(w.x, w.y) ## convert tile coords to world coords func tile_to_world(tx:float, ty:float, tz:float) -> Vector2: var n = pow(2.0, floor(tz)) var x = (tx / n) * 2.0 - 1.0 var y = -((ty / n) * 2.0 - 1.0) return Vector2(x,y) ## convert world coords to tile coords func world_to_tile(wx:float, wy:float, z:float, do_floor:bool=false) -> Vector2: var n = pow(2.0, floor(z)) var tx = ((wx+1.0) / 2.0) * n var ty = ((-wy + 1.0) / 2.0) * n if do_floor: tx = floor(tx) ty = floor(ty) return Vector2(tx,ty) ## convert world coords to screen coords func world_to_screen(wx:float, wy:float) -> Vector2: var n = pow(2.0, _xyz.z) var w = n * TILE_WIDTH var h = n * TILE_HEIGHT var xr = (((wx-_xyz.x)+1.0)/2.0) var yr = ((-(wy-_xyz.y)+1.0)/2.0) var x = w * xr - w/2 + size.x/2 var y = h * yr - h/2 + size.y/2 return Vector2(x,y) ## get the tile index from xyz tile coords func xyz_to_idx(x:int, y:int, z:int)->int: var i = (pow(4,z)-1) / 3 var n = pow(2,z) return i + (y * n + x) func get_tile(x:int, y:int, z:int, create:bool=true)->Tile: # out of bounds var n = pow(2, z) if z < 0 or x < 0 or x >= n or y < 0 or y >= n: return null # get tile index var idx = xyz_to_idx(x,y,z) var now = Time.get_unix_time_from_system() var tile = null # retrieve from error queue tile = _error.get(idx) if tile: return tile # retrieve from current requests... var req = find_child(str(idx), false, false) if req: tile = req.get_meta('tile') tile.t = now return tile # retrieve from cache tile = _cache.get(idx) if tile: tile.t = now return tile # retrieve from queue tile = _queue.get(idx) if tile: tile.t = now return tile # create a new tile - add it to queue if create: tile = Tile.new(idx,x,y,z) tile.url = base_url.replace('{x}',str(x)).replace('{y}', str(y)).replace('{z}', str(z)) tile.t = now _queue[idx] = tile return tile func lonlat_to_dms(lon:float, lat:float) -> String: var pf = 'N' if lat >= 0 else 'S' lat = abs(lat) var deg = floor(lat) var minute = (lat - deg) * 60 var second = (minute - floor(minute)) * 60 var text = ('%02d'%deg)+('%02d'%floor(minute))+('%02d'%floor(second))+(pf)+(' ') pf = 'E' if lon >= 0 else 'W' lon = abs(lon) deg = floor(lon) minute = (lon - deg) * 60 second = (minute - floor(minute)) * 60 text += ('%02d'%deg)+('%02d'%floor(minute))+('%02d'%floor(second))+(pf) return text class Tile: var i:int = 0 var x:int = 0 var y:int = 0 var z:int = 0 var t:int = 0 var url:String = '' var texture:Texture2D = null func _init(i:int, x:int, y:int, z:int): self.i = i self.x = x self.y = y self.z = z