@tool
extends CanvasGroup
class_name MercatorTileMap

const VECTOR00: = Vector2(0, 0)
const VECTOR11: = Vector2(1, 1)

## Отображение карт WEB Mercator

#http://10.1.1.220/osm_tiles/0/0/0.png
#http://169.254.27.71:8001/osm_tiles/{z}/{x}/{y}.png
#https://tile.openstreetmap.org/{z}/{x}/{y}.png
@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()
const TILE_REQUEST_NAME: = 'tile_request_%d'

@export var zoom_step: float = 0.1
@export var tile_width: float = 256.0
@export var tile_height: float = 256.0
@export_range(1, 1000, 1) var max_concurrent_requests:int = 5
@export_range(100, 100000, 1) var max_cached_tiles: int = 1024
@export_range(0, 20, 1) var max_zoom_level: int = 20
@export_range(0, 20, 1) var min_zoom_level: int = 0
@export_range(0, 20, 1) var max_zoom_level_server: int = 20
@export_color_no_alpha() var back_color: = Color.BLACK
@export var show_ship_radius: bool = true
@export var ship_radius_km: float = 8.0
@onready var coordinates_label: String
@onready var threat_mark: PackedScene = load('res://scenes/tilemap/threat.tscn')
@onready var ship_mark: PackedScene = load('res://scenes/tilemap/ship-mark.tscn')
var ship_id := 9999
var ship_instance: Node2D = null
const default_size: = 400.0
var is_centered: = false
var is_out_border1: = true
var is_out_border2: = true
var threat_markers := {}
var zoom_timer: Timer
var zoom_action: String

@export var animation_time: float = 0.1:
    set(val):
        animation_time = val


@export var size: Vector2i = Vector2i(default_size, default_size):
    set(v):
        $canvas.size = v
        _on_resize()
        queue_redraw()
    get:
        return $canvas.size


@export var ax: float = 0.0:
    set(v):
        _xyz.x = v
        queue_redraw()
    get:
        return _xyz.x


@export var ay: float = 0.0:
    set(v):
        _xyz.y = v
        queue_redraw()
    get:
        return _xyz.y


@export var az: float = 0.0:
    set(v):
        _xyz.z = v
        queue_redraw()
    get:
        return _xyz.z


var _xyz: Vector3 = Vector3(0, 0, 0)
var _cache: Dictionary = {}
var _queue: Dictionary = {}
var _dragging: bool = false
var _drag_pos: Vector2 = Vector2.ZERO
var _last_cache_check: float = 0
var _rollover: bool = false
var _marks: Dictionary = {}
var _req_count: = 0
var image: = Image.new()
var image_load_proc: Callable
var now: = 0.0
var visible_marks_count: = 0
var border1: ImageTexture
var border2: ImageTexture

## Вызывается после того, как загрузка плиток завершена
signal load_completed()

## Вызывается при запуске загрузки плиток
signal load_started()

## Вызывается при получении ответа от сервера карт
signal map_server_online()

## Вызывается при отсутствии ответа от сервера карт
signal map_server_offline()


const image_load_proc_names = {
    'png':  'load_png_from_buffer',
    'jpg':  'load_jpg_from_buffer',
    'tga':  'load_tga_from_buffer',
    'webp': 'load_webp_from_buffer' }


@export_enum('png', 'jpg', 'jpeg', 'tga', 'webp') var image_type: String = 'png':
    set(v): image_load_proc = Callable(image, image_load_proc_names[v])


func on_map_centered(toggle_centered: bool): is_centered = toggle_centered


func get_marks_count(): return _marks.size()


func _ready() -> void:
    var img: = Image.create(1, 2, false, Image.FORMAT_RGBA8)
    img.set_pixel(0, 0, Color.CORAL)
    img.set_pixel(0, 1, Color(1, 1, 1, 0))
    border1 = ImageTexture.create_from_image(img)
    img.set_pixel(0, 0, Color(1, 1, 1, 0))
    img.set_pixel(0, 1, Color.CORAL)
    border2 = ImageTexture.create_from_image(img)

    zoom_timer = Timer.new()
    add_child(zoom_timer)
    zoom_timer.one_shot = false
    zoom_timer.wait_time = 0.1
    zoom_timer.timeout.connect(_on_zoom_timeout)

    _xyz.z = min_zoom_level
    image_type = 'png'
    $canvas.connect('mouse_entered', func(): _rollover = true)
    $canvas.connect('mouse_exited',  func(): _rollover = false)
    _on_resize()
    signaller.connect('map_centered', on_map_centered)
    if not Engine.is_editor_hint():
        var unit_nav = network.get_unit_instance('навигация')
        unit_nav.connect('data_received', on_data_received)
    ProjectSettings.connect('settings_changed', _settings_changed)
    threats.connect('threat_new', Callable(self, '_on_threat_new'))
    threats.connect('threat_lost', Callable(self, '_on_threat_lost'))
    threats.connect('threat_update', Callable(self, '_on_threat_update'))


func _settings_changed():
    base_url = ProjectSettings.get_setting('application/config/Шаблон адреса сервера карт', base_url)


func on_data_received(coords: Dictionary) -> void:
    if not is_centered:
        return
    if coords.has_all(['latitude', 'longitude']):
        set_coordinates(coords['longitude'], coords['latitude'], coords['k'])


func _on_resize() -> void:
    var center: Vector2 = $canvas.size / 2.0
    $canvas/spr_mask.position = center
    $canvas/spr_mask.scale = $canvas.size / default_size


## Создаёт новую отметку из координат экрана
func add_mark_from_screen_pos(id: int, scr_xy: Vector2, mark: Node) -> bool:
    if _marks.has(id):
        return false
    var wrld_pos: Vector2 = screen_to_world(_xyz, scr_xy)
    _marks[id] = { 'position': wrld_pos, 'mark': mark, 'gap_size': 0.0 }
    add_child(mark)
    return true


func delete_mark(id: int) -> bool:
    if not _marks.has(id):
        return false
    _marks[id].mark.queue_free()
    _marks.erase(id)
    queue_redraw()
    return true


## Создаёт новую отметку из долготы и широты
func add_mark_from_lon_lat(id: int, lon: float, lat: float, mark: Node, gap_size: float = 32) -> bool:
    if _marks.has(id):
        return false
    var wrld_pos: Vector2 = lonlat_to_world(lon, lat)
    _marks[id] = {'position': wrld_pos, 'mark': mark, 'gap_size': gap_size}
    add_child(mark)
    queue_redraw()
    return true


## Возвращает новую позицию для точки, если она выходит из поля видимости
func fix_mark_position(dot) -> void:
    if is_mark_visible(dot):
        return
    var v = 2 * tile_width * pow(2, _xyz.z)
    if dot.position.x < _xyz.x - $canvas.size.x / v:
        dot.position.x = _xyz.x - $canvas.size.x / v
    elif dot.position.x > _xyz.x + $canvas.size.x / v:
        dot.position.x = _xyz.x + $canvas.size.x / v


func is_mark_visible(dot) -> bool:
    var screen_pos = world_to_screen(dot.position)
    return screen_pos.x > 0 and screen_pos.x < $canvas.size.x


func _input(e) -> void:
    var lmp = get_local_mouse_position()
    if not _rollover:
        _dragging = false
        return
    if e is InputEventMouseButton:
        if e.pressed:
            if e.button_index == MOUSE_BUTTON_WHEEL_DOWN:
                if (not is_out_border1) and (not is_out_border2):
                    increment_zoom(-zoom_step, lmp)
                    update_scale_label()
                    queue_redraw()
            elif e.button_index == MOUSE_BUTTON_WHEEL_UP:
                increment_zoom( zoom_step, lmp)
                update_scale_label()
                queue_redraw()
        if e.button_index == MOUSE_BUTTON_LEFT:
            _dragging = e.pressed
            _drag_pos = screen_to_world(_xyz, lmp)

    elif e is InputEventMouseMotion:
        if _dragging:
            var wp = screen_to_world(_xyz, lmp)
            var diff = _drag_pos - wp
            _xyz.x += diff.x

            if (not is_out_border1) and diff.y >= 0:
                _xyz.y += diff.y
            if is_out_border1 and diff.y <= 0:
                _xyz.y += diff.y

            if is_out_border2 and diff.y >= 0:
                _xyz.y += diff.y
            if (not is_out_border2) and diff.y <= 0:
                _xyz.y += diff.y

            queue_redraw()
            if diff.length() > 0:
                signaller.emmit(signaller.map_user_panning)


func increment_zoom(increment: float, pivot: Vector2) -> void:
    var p1: = screen_to_world(_xyz, pivot)
    _xyz.z = clampf(_xyz.z + increment, min_zoom_level, max_zoom_level)
    var p2: = screen_to_world(_xyz, pivot)
    _xyz.x -= p2.x - p1.x
    _xyz.y -= p2.y - p1.y


func set_zoom(zoom: float, pivot: Vector2) -> void:
    var p1: = screen_to_world(_xyz, pivot)
    _xyz.z = clampf(zoom,  max_zoom_level, min_zoom_level)
    var p2: = screen_to_world(_xyz, pivot)
    _xyz.x -= p2.x - p1.x
    _xyz.y -= p2.y - p1.y


func _draw() -> void:
    # Рисование плиток
    var z: float = min(max_zoom_level, _xyz.z)
    var t1: = screen_to_tile(VECTOR00, z)
    var t2: = screen_to_tile($canvas.size, z) + VECTOR11
    is_out_border1 = true
    is_out_border2 = true
    for tx in range(t1.x, t2.x):
        for ty in range(t1.y, t2.y):
            _draw_tile(tx, ty, z)
    # Рисование отметок
    var n: = pow(2, z)
    var dx: = n * tile_width
    var c: Vector2 = $canvas.size / 2.0
    var r: float = c.length()
    visible_marks_count = 0
    for dot in _marks.values():
        var scr_pos: Vector2 = world_to_screen(dot.position)
        scr_pos.x = posmod(int(scr_pos.x), int(dx))
        dot.mark.position = scr_pos
        dot.mark.visible = is_mark_visible(dot)
        dot.mark.visible = c.distance_to(scr_pos) < (r - dot.gap_size)
        visible_marks_count += int(dot.mark.visible)
    # Рисование границ
    var p1: Vector2 = $canvas.position
    var p2: Vector2 = $canvas.size / Vector2(1, 20)
    if is_out_border1 and _dragging:
        draw_texture_rect(border1, Rect2(p1, p2), false, Color.WHITE, false)
    p1.y = p1.y + $canvas.size.y - p2.y
    if is_out_border2 and _dragging:
        draw_texture_rect(border2, Rect2(p1, p2), false, Color.WHITE, false)
    if should_draw_ship_radius():
        draw_ship_radius()


func _clean_all() -> void:
    _queue.clear()
    _cache.clear()
    _req_count = 0


# get the tile from queue with the newest timestamp
func get_next_in_queue() -> Tile:
    var tile = null
    for idx in _queue:
        var t = _queue.get(idx)
        if not tile or t.t > tile.t:
            tile = t
    return tile


func _clean_cache() -> void:
    var overflow = _cache.size() - max_cached_tiles
    if overflow <= 0:
        return

    var vals = _cache.values()
    vals.sort_custom(func(t1, t2): return t1.t < t2.t)
    for i in overflow:
        _cache.erase(vals[i].i)


func _process(delta) -> void:
    now += delta

    # Если кэш переполнен - очистить его.
    if not _last_cache_check or now - _last_cache_check > 5:
        _last_cache_check = now
        _clean_cache()

    while not _queue.is_empty() and (_req_count < max_concurrent_requests):

        var tile = get_next_in_queue()
        if not tile:
            break

        var req = HTTPRequest.new()
        _req_count += 1
        req.set_meta('tile', tile)
        add_child(req)
        req.name = NodePath(TILE_REQUEST_NAME % tile.i)

        _queue.erase(tile.i)
        req.request_completed.connect(_response.bind(req, tile))

        if req.request(tile.url) != OK:
            tile.t = now
            remove_child(req)
            req.queue_free()
            _req_count -= 1
            push_error('запрос \"%s\" не отправлен' % tile.url)
        if _queue.is_empty():
            emit_signal('load_completed')


func process_online(result):
    # ERROR: res://scenes/tilemap/tilemap.gd:396 - Invalid access to property or key 'online' on a base object of type 'Nil'.
    var unit_instance: = network.get_unit_instance('уарэп-карта')
    if not unit_instance: return true

    if result != HTTPRequest.RESULT_SUCCESS:
        if unit_instance.online:
            unit_instance.online = false
            unit_instance.emit_signal('line_changed', unit_instance)
        return false

    unit_instance.rx_tick = Time.get_ticks_msec()
    if not unit_instance.online:
        unit_instance.online = true
        unit_instance.emit_signal('line_changed', unit_instance)
    return true


func _response(result, code, _headers: Array, body: PackedByteArray, req, tile: Tile) -> void:
    _req_count -= 1
    remove_child(req)
    req.queue_free()

    if not process_online(result):
        _queue[tile.i] = tile
        return

    if code == 404:
        push_error('на сервере нет \"%s\"' % tile.url)
        return

    if Error.OK != image_load_proc.call(body):
        push_error('не удалось получить изображение (неверный формат?) из \"%s\" (%d байт)' % [tile.url, body.size()])
        return

    tile.texture = ImageTexture.create_from_image(image)
    _cache[tile.i] = tile

    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:
        check_borders(p1, p2, $canvas.size.y)
        draw_texture_rect_region(subtile.texture, Rect2(p1, p2 - p1), rect)
        return true
    return false


func check_borders(p1, p2, h):
    if p1.y < 0:
        is_out_border1 = false
    if p1.y + (p2 - p1).y > h:
        is_out_border2 = false


func _draw_tile(tx: int, ty: int, z: float) -> void:
    var tz: = int(floor(z))
    var p1: = tile_to_screen(tx, ty, z)
    var p2: = tile_to_screen(tx + 1, ty + 1, z)
    var tile: = get_tile(tx, ty, tz)
    if tile:
        if tile.texture:
            check_borders(p1, p2, $canvas.size.y)
            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.0)
                tyy = floor(tyy / 2.0)
                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: float = (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: Vector2 = lonlat_to_world(lon, lat)
    return world_to_screen(w)


## convert lon/lat to tile coords
func lonlat_to_tile(lon: float, lat: float, z: float) -> Vector2:
    var w: Vector2 = lonlat_to_world(lon,lat)
    return world_to_tile(w.x, w.y, z)


## convert world coords to lon/lat
func world_to_lonlat(wx: float, wy: float) -> Vector2:
    var lon: float = wx * 180.0
    var lat: float = rad_to_deg(atan(sinh(wy * PI)))
    return Vector2(lon, lat)


## convert screen coords to lon/lat
func screen_to_lonlat(scr_xy: Vector2) -> Vector2:
    var w: Vector2 = screen_to_world(_xyz, scr_xy)
    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: Vector2 = tile_to_world(tx, ty, tz)
    return world_to_lonlat(w.x, w.y)


## convert screen coords to world coords
func screen_to_world(xyz: Vector3, s: Vector2) -> Vector2:
    var n: float = pow(2.0, xyz.z)
    var span_w: float = n * tile_width
    var span_h: float = n * tile_height
    var px: float = s.x - $canvas.size.x / 2 + span_w / 2
    var py: float = s.y - $canvas.size.y / 2 + span_h / 2
    var xr: float = px / span_w
    var yr: float = py / span_h
    var x: float = (xr * 2.0 - 1.0) + xyz.x
    var y: float = ((-yr * 2.0) + 1.0) + xyz.y
    return Vector2(x, y)


## convert screen coords to tile coords
func screen_to_tile(scr_xy: Vector2, z: float) -> Vector2:
    var world: Vector2 = screen_to_world(_xyz, scr_xy)
    return world_to_tile(world.x, world.y, z)


## convert tile coords to screen coords
func tile_to_screen(tx: float, ty: float, tz: float) -> Vector2:
    var w: Vector2 = tile_to_world(tx, ty, tz)
    return world_to_screen(w)


## convert tile coords to world coords
func tile_to_world(tx: float, ty: float, tz: float) -> Vector2:
    var n: float = pow(2.0, floor(tz))
    var x: float = (tx / n) * 2.0 - 1.0
    var y: float = -((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) -> Vector2:
    var n: float = pow(2.0, floor(z))
    var tx: float = floor(((wx + 1.0) / 2.0) * n)
    var ty: float = floor(((-wy + 1.0) / 2.0) * n)
    return Vector2(tx, ty)


## convert world coords to screen coords
func world_to_screen(pos: Vector2) -> Vector2:
    var n: = pow(2.0, _xyz.z)
    var wh: = Vector2(n * tile_width, n * tile_height)
    var rxy: = Vector2(((pos.x - _xyz.x) + 1.0) / 2.0, (-(pos.y - _xyz.y) + 1.0) / 2.0)
    return wh * rxy + ($canvas.size - wh) / 2.0


## get the tile index from xyz tile coords
func xyz_to_idx(x: float, y: float, z: float) -> int:
    var i: = (pow(4.0, z) - 1.0) / 3.0
    var n: =  pow(2.0, z)
    return int(i + (y * n + x))


func get_tile(x: int, y: int, z: int) -> Tile:

    var n: float = pow(2, z)
    if x >= 0:
        x %= int(n)
    else:
        x = -x - 1
        x = int(n - 1) - (x % int(n))

    if z < 0 or y < 0 or y >= n:
        return null

    var idx: int = xyz_to_idx(x, y, z)

    var tile = null
    var req: = get_node_or_null(NodePath(TILE_REQUEST_NAME % idx))
    if req:
        tile = req.get_meta('tile')
        if tile != null:
            tile.t = now
            return tile

    tile = _cache.get(idx)
    if tile:
        tile.t = now
        return tile

    tile = _queue.get(idx)
    if tile:
        tile.t = now
        return tile

    var queue_size: = _queue.size()
    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
    if z <= max_zoom_level_server:
        _queue[idx] = tile

    if queue_size == 0 and _queue.size():
        emit_signal('load_started')

    return tile


func lonlat_to_dms(lon: float, lat: float) -> String:
    var pf: String = 'N' if lat >= 0 else 'S'
    lat = abs(lat)
    var deg: float = floor(lat)
    var minute: float = (lat - deg) * 60
    var second: float = (minute - floor(minute)) * 60
    var text: String = ('%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: float = 0.0
    var url: String = ''
    var texture: Texture2D = null

    func _init(i0: int, x0: int, y0: int, z0: int):
        self.i = i0
        self.x = x0
        self.y = y0
        self.z = z0

# Возвращает расстояние в метрах между двумя точками на карте
func haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    # Радиус Земли в метрах
    const R: = 6372795.0
    # Преобразование градусов в радианы
    var rlat1: = deg_to_rad(lat1)
    var rlon1: = deg_to_rad(lon1)
    var rlat2: = deg_to_rad(lat2)
    var rlon2: = deg_to_rad(lon2)
    # вычисление длины большого круга
    var cl1: = cos(rlat1)
    var cl2: = cos(rlat2)
    var sl1: = sin(rlat1)
    var sl2: = sin(rlat2)
    var delta: = rlon2 - rlon1
    var cdelta: = cos(delta)
    var sdelta: = sin(delta)
    var y: = sqrt(pow(cl2 * sdelta, 2.0) + pow(cl1 * sl2 - sl1 * cl2 * cdelta, 2.0))
    var x: = sl1 * sl2 + cl1 * cl2 * cdelta
    var ad: = atan2(y, x)
    var dist: = ad * R
    return dist


func remove_closest_dot(screen_pos: Vector2) -> bool:
    var closest_dot = find_at_screen_position(screen_pos)
    if not closest_dot:
        return false
    remove_child(closest_dot.mark)
    closest_dot.mark.queue_free()
    _marks.erase(closest_dot)
    return true


func find_at_screen_position(screen_pos: Vector2) -> Node:
    var closest_dot = null
    var closest_distance = INF
    for mark in _marks.values():
        var dot_screen_pos = world_to_screen(mark.position)
        var distance = dot_screen_pos.distance_to(screen_pos)
        if distance < closest_distance:
            closest_distance = distance
            closest_dot = mark
    return closest_dot


func clear() -> void:
    _clean_all()
    queue_redraw()


func set_coordinates(lon: float, lat: float, course: float) -> void:
    var anim: Animation = $player.get_animation('goto')
    var new_pos: Vector2 = lonlat_to_world(lon, lat)
    anim.track_insert_key(0, 0, _xyz.x)
    anim.track_insert_key(1, 0, _xyz.y)
    anim.track_insert_key(0, animation_time, new_pos.x)
    anim.track_insert_key(1, animation_time, new_pos.y)
    $player.play('goto')
    update_coordinates_label(lon, lat)
    if not _marks.has(ship_id):
        ship_instance = ship_mark.instantiate()
        add_mark_from_lon_lat(ship_id, lon, lat, ship_instance, 0.0)
    else:
        _marks[ship_id].position = lonlat_to_world(lon, lat)
        ship_instance.rotation_degrees = fmod(course, 360.0)
    queue_redraw()


func blend_mask(val: bool) -> void:
    $canvas/spr_mask.visible = val
    queue_redraw()


func update_coordinates_label(lon: float, lat: float) -> void:
    coordinates_label = 'Координаты: NS:%f, EW:%f' % [lat, lon]
    signaller.emit_signal('update_coordinates_map', coordinates_label)


func update_scale_label() -> void:
    var scale_label: = 'Масштаб: %f' % round(_xyz.z)
    signaller.emit_signal('update_scale_map', scale_label)


func _on_threat_new(th) -> void:
    var bip_instance = threat_mark.instantiate()
    if bip_instance:
        bip_instance.name = str(th.id)
        bip_instance.position = lonlat_to_world(th.lon, th.lat)
        add_mark_from_lon_lat(th.id, th.lon, th.lat, bip_instance, 32)


func _on_threat_update(th) -> void:
    var target = get_parent().get_node_or_null(str(th.id))
    if not target:
        return
    var new_position = lonlat_to_world(th.lon, th.lat)
    if _marks.has(th.id):
        _marks[th.id].position = new_position
    if new_position != target.position:
        target.position = new_position


func _on_threat_lost(th) -> void:
    var target = get_parent().get_node_or_null(str(th.id))
    if target:
        target.queue_free()


func zoom_in() -> void:
    if _xyz.z >= max_zoom_level:
        return
    var center: Vector2 = $canvas.size / 2.0
    increment_zoom(zoom_step, center)
    update_scale_label()
    queue_redraw()


func zoom_out() -> void:
    if _xyz.z <= min_zoom_level:
        return
    var center: Vector2 = $canvas.size / 2.0
    increment_zoom(-zoom_step, center)
    update_scale_label()
    queue_redraw()


func _on_zoom_plus_button_down() -> void:
    zoom_action = 'in'
    perform_zoom()
    zoom_timer.start(0.3)


func _on_zoom_minus_button_down() -> void:
    zoom_action = 'out'
    perform_zoom()
    zoom_timer.start(0.3)


func _on_zoom_button_up() -> void:
    zoom_action = ''
    zoom_timer.stop()


func _on_zoom_timeout():
    perform_zoom()
    zoom_timer.wait_time = 0.1


func perform_zoom():
    if zoom_action != '':
        if zoom_action == 'in':
            zoom_in()
        else:
            zoom_out()
    update_scale_label()


# Вычисление для радиуса в мировых координатах
func get_ship_radius_world() -> float:
    if not _marks.has(ship_id):
        return 0.0
    var ship_world_pos = _marks[ship_id].position
    var ship_lonlat = world_to_lonlat(ship_world_pos.x, ship_world_pos.y)
    var earth_radius_km = 6371.0
    var distance_deg = (ship_radius_km / earth_radius_km) * (180.0 / PI) / cos(deg_to_rad(ship_lonlat.y))
    var east_lon = ship_lonlat.x + distance_deg
    var east_point_world = lonlat_to_world(east_lon, ship_lonlat.y)
    return abs(east_point_world.x - ship_world_pos.x)


func should_draw_ship_radius() -> bool:
    if not show_ship_radius or not _marks.has(ship_id):
        return false
    return _xyz.z >= 11


func draw_ship_radius() -> void:
    var ship_world_pos = _marks[ship_id].position
    var ship_screen_pos = world_to_screen(ship_world_pos)
    var radius_world = get_ship_radius_world()
    var radius_point_world = Vector2(ship_world_pos.x + radius_world, ship_world_pos.y)
    var radius_point_screen = world_to_screen(radius_point_world)
    var radius_screen = ship_screen_pos.distance_to(radius_point_screen)
    draw_circle(ship_screen_pos, radius_screen, Color.ORANGE_RED * Color(1, 1, 1, 0.1)) # отрисовка заливки
