Files
car-os/map_viewer.gd
T
2026-07-04 18:22:23 -06:00

455 lines
12 KiB
GDScript

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