coordconver 0.1.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,60 +1,57 @@
1
- module Coordconver
2
- module Degree
3
- def self.included(base)
4
- base.extend(ClassMethods)
5
- end
6
-
7
- module ClassMethods
8
-
9
- # 取整数位
10
- def num(n)
11
- n.to_i
12
- end
13
-
14
- # 取小数数位 Decimal digit
15
- def dig(n)
16
- n.to_f-num(n)
17
- end
18
-
19
- # 计算分
20
- def min(n)
21
- dig(n)*60
22
- end
23
-
24
- # 计算秒
25
- def sec(n)
26
- (min(n)-num(min(n)))*60
27
- end
28
-
29
- # 坐标转度数
30
- def degree(lng, lat)
31
- [cal_degree(lng),cal_degree(lat)]
32
- end
1
+ # frozen_string_literal: true
33
2
 
34
- def cal_degree(n)
35
- "#{num(n)}°#{num(min(n))}′#{num(sec(n))}″"
36
- end
37
-
38
-
39
- # 获取分
40
- def get_min(n)
41
- n.split('°').last.to_f
42
- end
43
-
44
- # 获取秒
45
- def get_sec(n)
46
- n.split('′').last.to_f
47
- end
48
-
49
- # 度数转坐标
50
- def cal_undegree(n)
51
- (num(n) + get_min(n)/60 + get_sec(n)/3600).round(5)
52
- end
53
-
54
- def undegree(lng, lat)
55
- [cal_undegree(lng),cal_undegree(lat)]
56
- end
57
-
58
- end
59
- end
3
+ # 经纬度(十进制度)与度分秒(DMS)互转。
4
+ #
5
+ # 经 Coordconver.extend(Degree) 挂载, 以下两种写法等价:
6
+ # Coordconver.degree(38.65777, 104.08296)
7
+ # Coordconver::Degree.degree(38.65777, 104.08296)
8
+ module Coordconver
9
+ module Degree
10
+ extend self
11
+
12
+ # 坐标转度分秒, 返回 [经度, 纬度] 的 DMS 字符串。
13
+ # 负值按西经/南纬处理, 取绝对值后再格式化。
14
+ # Coordconver.degree(38.65777, 104.08296) # => ["38°39′27.97″", "104°4′58.66″"]
15
+ def degree(lng, lat)
16
+ [cal_degree(lng), cal_degree(lat)]
17
+ end
18
+
19
+ # 单个十进制度数转度分秒字符串, 默认保留两位小数秒。
20
+ # Coordconver.cal_degree(38.65777) # => "38°39′27.97″"
21
+ # Coordconver.cal_degree(38.65777, 0) # => "38°39′27″"
22
+ def cal_degree(n, precision = 2)
23
+ sign = n.negative? ? "-" : ""
24
+ n = n.abs
25
+ d = n.to_i
26
+ m = (n - d) * 60
27
+ s = (m - m.to_i) * 60
28
+ if precision.zero?
29
+ "#{sign}#{d}°#{m.to_i}′#{s.round}″"
30
+ else
31
+ "#{sign}#{d}°#{m.to_i}′#{s.round(precision)}″"
32
+ end
33
+ end
34
+
35
+ # 度分秒转坐标, 返回 [经度, 纬度] 浮点值。
36
+ # 兼容 "38°39′27.97″"、"38°39′27.97""、"38d39m27.97s"、"38:39:27.97" 等写法。
37
+ # Coordconver.undegree("38°39′27.97″", "104°4′58.66″") # => [38.65777, 104.08296]
38
+ def undegree(lng, lat)
39
+ [cal_undegree(lng), cal_undegree(lat)]
40
+ end
41
+
42
+ # 单个度分秒字符串转十进制度数。
43
+ # Coordconver.cal_undegree("104°4′58.66″") # => 104.08296
44
+ # Coordconver.cal_undegree("-38°39′27.97″") # => -38.65777
45
+ def cal_undegree(str)
46
+ parts = str.to_s.scan(/[-+]?\d+(?:\.\d+)?/)
47
+ return 0.0 if parts.empty?
48
+
49
+ deg, min, sec = parts.map(&:to_f)
50
+ min ||= 0.0
51
+ sec ||= 0.0
52
+ value = deg.abs + min / 60.0 + sec / 3600.0
53
+ value = -value if str.to_s =~ /^\s*[-–]/ || deg.negative?
54
+ value.round(6)
55
+ end
56
+ end
60
57
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ # 统一错误基类, 便于业务侧 rescue Coordconver::Error
5
+ class Error < StandardError; end
6
+
7
+ # 缺少 API key 等配置问题时抛出
8
+ class ConfigurationError < Error; end
9
+
10
+ # 网络超时、连接失败、响应非 JSON 时抛出
11
+ class NetworkError < Error; end
12
+
13
+ # 服务端返回业务错误(如 key 无效、配额超限)时抛出
14
+ class ApiError < Error
15
+ attr_reader :code, :raw
16
+
17
+ def initialize(message = nil, code: nil, raw: nil)
18
+ super(message)
19
+ @code = code
20
+ @raw = raw
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # WKT / GeoJSON 与坐标互转。
6
+ # 经 Coordconver.extend(Formats) 挂载:
7
+ # Coordconver.parse_wkt("POINT (121.47 31.25)") # => [121.47, 31.25]
8
+ # Coordconver.to_wkt(121.47, 31.25) # => "POINT (121.47 31.25)"
9
+ # Coordconver.to_geojson(121.47, 31.25) # => {"type"=>"Point", "coordinates"=>[121.47, 31.25]}
10
+ module Coordconver
11
+ module Formats
12
+ extend self
13
+
14
+ # 解析 WKT 点: "POINT (121.47 31.25)" / "POINT(121.47 31.25)" -> [lng, lat]
15
+ def parse_wkt(wkt)
16
+ match = wkt.to_s.match(/point\s*z?m?\s*\(\s*([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)/i)
17
+ raise ArgumentError, "无法解析 WKT: #{wkt.inspect} (仅支持 POINT)" unless match
18
+
19
+ [match[1].to_f, match[2].to_f]
20
+ end
21
+
22
+ # 生成 WKT 点
23
+ def to_wkt(lng, lat)
24
+ "POINT (#{lng} #{lat})"
25
+ end
26
+
27
+ # 解析 GeoJSON: 接受 Hash 或 JSON 字符串。
28
+ # 支持 Point 与包着 Point 的 Feature。
29
+ # 返回 [lng, lat]
30
+ def parse_geojson(input)
31
+ obj = input.is_a?(Hash) ? input : JSON.parse(input.to_s)
32
+ obj = obj["geometry"] if obj["type"] == "Feature"
33
+ raise ArgumentError, "仅支持 GeoJSON Point, 实际: #{obj['type'].inspect}" unless obj["type"] == "Point"
34
+
35
+ coords = obj["coordinates"]
36
+ raise ArgumentError, "GeoJSON Point 缺少 coordinates" unless coords.is_a?(Array) && coords.size >= 2
37
+
38
+ [coords[0].to_f, coords[1].to_f]
39
+ end
40
+
41
+ # 生成 GeoJSON Point Hash (GeoJSON 规范中坐标为 [lng, lat] 顺序)
42
+ def to_geojson(lng, lat)
43
+ { "type" => "Point", "coordinates" => [lng, lat] }
44
+ end
45
+
46
+ # 生成 GeoJSON Point JSON 字符串
47
+ def to_geojson_string(lng, lat)
48
+ JSON.generate(to_geojson(lng, lat))
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ module Geo
5
+ # 百度/高德/腾讯 API 的统一调度层:
6
+ # 组装 key -> LRU 缓存 -> 请求 -> 识别 key 类失败 -> 轮换下一个 key 重试。
7
+ module Api
8
+ # provider: :baidu / :gaode / :tencent
9
+ # params: 业务参数(不含 key)
10
+ # key_param: key 参数名 (:ak / :key)
11
+ # sign: 可选 Proc, 对含 key 的完整参数做签名(百度 SN 校验)
12
+ # bad_result: Proc, 判断响应是否属于"key 无效/配额超限"类失败(应轮换 key)
13
+ def self.call(provider:, url:, params:, key_param:, sign: nil, bad_result:)
14
+ keys = Configuration.keys_for(provider)
15
+ attempts = 0
16
+ loop do
17
+ key = KeyPool.next_key(provider, keys)
18
+ full_params = params.merge(key_param => key)
19
+ full_params = sign.call(full_params) if sign
20
+ cache_key = [provider, url, full_params]
21
+
22
+ hit = Cache.get(cache_key)
23
+ if hit
24
+ return hit unless bad_result.call(hit)
25
+
26
+ Cache.delete(cache_key) # 防御: 不应缓存过失败响应
27
+ next
28
+ end
29
+
30
+ response = Utils::Request.get(url, params: full_params)
31
+ return Cache.set(cache_key, response) unless bad_result.call(response)
32
+
33
+ KeyPool.mark_bad(provider, key)
34
+ attempts += 1
35
+ if attempts >= keys.size
36
+ raise ApiError.new(
37
+ "#{provider} 请求失败: 所有 #{keys.size} 个 key 均无效或配额超限",
38
+ code: provider, raw: response
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Coordconver
6
+ module Geo
7
+ # 百度地图 Web 服务 API (Geocoding API v3)
8
+ # 申请 key: https://lbsyun.baidu.com/apiconsole/key
9
+ # 建议创建"服务端"类型 AK 并配置 IP 白名单; 若 AK 类型为"SN 校验", 需同时配置 baidu_sn
10
+ module Baidu
11
+ extend self
12
+
13
+ # 视为"key 无效/配额超限"的状态码, 触发自动轮换下一个 key
14
+ # 200 AK不存在 201 AK被禁用 210 AK参数错误 401 配额超限 402 每秒请求量超限
15
+ KEY_ERROR_STATUSES = [200, 201, 210, 401, 402].freeze
16
+
17
+ # 地理编码: 结构化地址 -> 坐标
18
+ # Coordconver::Baidu.geo("百度大厦")["result"]["location"] # => {"lng"=>116.30..., "lat"=>40.05...}
19
+ # 支持可选参数: city(城市名), ret_coordtype(返回坐标类型, 默认 bd09ll)
20
+ def geo(address, options = {})
21
+ params = {
22
+ address: address,
23
+ output: "json"
24
+ }.merge(options)
25
+ request("/geocoding/v3/", params)
26
+ end
27
+
28
+ # 逆地理编码: 坐标 -> 结构化地址
29
+ # Coordconver::Baidu.regeo(116.322987, 39.983424)["result"]["formatted_address"]
30
+ # options: coordtype(输入坐标类型, 默认 bd09ll, 可选 wgs84ll / gcj02ll 等)
31
+ def regeo(lng, lat, options = {})
32
+ params = {
33
+ location: "#{lat},#{lng}",
34
+ output: "json",
35
+ coordtype: "bd09ll"
36
+ }.merge(options)
37
+ request("/reverse_geocoding/v3/", params)
38
+ end
39
+
40
+ # IP 定位: IP -> 城市 + 坐标 (BD-09)
41
+ def ip(ip = nil)
42
+ params = { coor: "bd09ll", output: "json" }
43
+ params[:ip] = ip unless ip.nil? || ip.empty?
44
+ request("/location/ip", params)
45
+ end
46
+
47
+ # 便捷方法: 地址 -> [lng, lat] (BD-09), 解析失败返回 nil
48
+ def geo_location(address, options = {})
49
+ loc = geo(address, options).dig("result", "location")
50
+ loc && [loc["lng"], loc["lat"]]
51
+ end
52
+
53
+ # 便捷方法: 坐标 -> 地址字符串 (BD-09), 解析失败返回 nil
54
+ def regeo_address(lng, lat, options = {})
55
+ regeo(lng, lat, options).dig("result", "formatted_address")
56
+ end
57
+
58
+ private
59
+
60
+ def request(path, params)
61
+ sn_key = Configuration.settings[:baidu_sn]
62
+ Api.call(
63
+ provider: :baidu,
64
+ url: base_url + path,
65
+ params: params,
66
+ key_param: :ak,
67
+ sign: sn_key ? ->(p) { p.merge(sn: sn(path, p, sn_key)) } : nil,
68
+ bad_result: ->(r) { KEY_ERROR_STATUSES.include?(r["status"].to_i) }
69
+ )
70
+ end
71
+
72
+ def base_url
73
+ Configuration.settings[:baidu_base_url].sub(%r{/\z}, "")
74
+ end
75
+
76
+ # SN 校验: sn = md5(urlencode(path + "?" + 按参数名字典序拼接的 query + sk))
77
+ def sn(path, params, sk)
78
+ query = params.sort.map { |k, v| "#{k}=#{v}" }.join("&")
79
+ Digest::MD5.hexdigest(custom_uri_encode("#{path}?#{query}") + sk)
80
+ end
81
+
82
+ # 百度 SN 校验要求的 URI 编码: 仅 A-Za-z0-9-_.~ 不编码, 其余按字节转 %XX 大写
83
+ def custom_uri_encode(str)
84
+ str.gsub(/[^A-Za-z0-9\-_.~]/) do |char|
85
+ char.bytes.map { |b| format("%%%02X", b) }.join
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ module Geo
5
+ # 进程内 LRU + TTL 缓存, 用于地理编码结果去重以节省配额。
6
+ # 默认关闭, 通过 config.cache = true 开启; 可配置 cache_ttl / cache_size。
7
+ module Cache
8
+ DEFAULT_TTL = 3600
9
+ DEFAULT_SIZE = 1024
10
+
11
+ @store = {}
12
+ @mutex = Mutex.new
13
+
14
+ class << self
15
+ def enabled?
16
+ Configuration.configured? && Configuration.settings[:cache] ? true : false
17
+ end
18
+
19
+ # 命中且未过期返回值, 否则返回 nil
20
+ def get(key)
21
+ return nil unless enabled?
22
+
23
+ @mutex.synchronize do
24
+ entry = @store[key]
25
+ return nil unless entry
26
+ return nil if Time.now - entry[:at] > ttl
27
+
28
+ @store.delete(key)
29
+ @store[key] = entry # LRU: 重新插入到队尾
30
+ entry[:value]
31
+ end
32
+ end
33
+
34
+ # 存入并返回 value; 未启用时原样返回 value
35
+ def set(key, value)
36
+ return value unless enabled?
37
+
38
+ @mutex.synchronize do
39
+ @store.delete(key)
40
+ @store[key] = { at: Time.now, value: value }
41
+ @store.shift while @store.size > size
42
+ value
43
+ end
44
+ end
45
+
46
+ def delete(key)
47
+ @mutex.synchronize { @store.delete(key) }
48
+ end
49
+
50
+ def clear!
51
+ @mutex.synchronize { @store.clear }
52
+ end
53
+
54
+ private
55
+
56
+ def ttl
57
+ Configuration.settings[:cache_ttl] || DEFAULT_TTL
58
+ end
59
+
60
+ def size
61
+ Configuration.settings[:cache_size] || DEFAULT_SIZE
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ module Geo
5
+ # 初始化配置。保留旧写法, 也可用 Coordconver.configure 块:
6
+ # Coordconver::Client.new(["百度ak1", "百度ak2"], ["高德key1"])
7
+ class Client
8
+ def initialize(baidu_map_aks = [], gaode_map_keys = [], options = {})
9
+ Configuration.init(baidu_map_aks, gaode_map_keys, options)
10
+ end
11
+ end
12
+ end
13
+
14
+ module_function
15
+
16
+ # 全局配置块:
17
+ # Coordconver.configure do |config|
18
+ # config.baidu_map_aks = ["ak1"]
19
+ # config.gaode_map_keys = ["key1"]
20
+ # end
21
+ def configure
22
+ Geo::Configuration.init unless Geo::Configuration.configured?
23
+ yield Geo::Configuration
24
+ end
25
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ module Geo
5
+ # 全局配置。支持两种写法:
6
+ #
7
+ # # 推荐: 全局配置块, key 支持数组以实现多 key 轮询
8
+ # Coordconver.configure do |config|
9
+ # config.baidu_map_aks = ["你的百度ak1", "你的百度ak2"]
10
+ # config.gaode_map_keys = ["你的高德key1", "你的高德key2"]
11
+ # config.tencent_map_keys = ["你的腾讯key1"]
12
+ # # 可选: 百度 SN 校验(仅当 AK 类型为"SN 校验"时需要), 服务端 AK 用 IP 白名单则无需配置
13
+ # config.baidu_sn = "你的百度SK"
14
+ # # 可选: 进程内缓存地理编码结果, 节省配额 (默认 false)
15
+ # config.cache = true
16
+ # config.cache_ttl = 3600 # 缓存秒数, 默认 3600
17
+ # config.cache_size = 1024 # 缓存条目上限, 默认 1024 (LRU 淘汰)
18
+ # # 可选: 超时(秒)
19
+ # config.open_timeout = 5
20
+ # config.read_timeout = 10
21
+ # end
22
+ #
23
+ # # 兼容旧写法
24
+ # Coordconver::Client.new(["百度ak"], ["高德key"], tencent_map_keys: ["腾讯key"])
25
+ module Configuration
26
+ extend self
27
+
28
+ DEFAULT_MAP_OPTIONS = {
29
+ baidu_base_url: "https://api.map.baidu.com",
30
+ gaode_base_url: "https://restapi.amap.com/v3",
31
+ tencent_base_url: "https://apis.map.qq.com/ws",
32
+ open_timeout: 5,
33
+ read_timeout: 10,
34
+ cache: false,
35
+ cache_ttl: 3600,
36
+ cache_size: 1024
37
+ }.freeze
38
+
39
+ PROVIDER_KEY_FIELD = {
40
+ baidu: :baidu_map_aks,
41
+ gaode: :gaode_map_keys,
42
+ tencent: :tencent_map_keys
43
+ }.freeze
44
+
45
+ attr_reader :settings
46
+
47
+ def configured?
48
+ !@settings.nil?
49
+ end
50
+
51
+ def init(baidu_map_aks = nil, gaode_map_keys = nil, options = {})
52
+ tencent_keys = Array(options.delete(:tencent_map_keys))
53
+ @settings = DEFAULT_MAP_OPTIONS.merge(
54
+ baidu_map_aks: Array(baidu_map_aks).compact.reject(&:empty?),
55
+ gaode_map_keys: Array(gaode_map_keys).compact.reject(&:empty?),
56
+ tencent_map_keys: tencent_keys.compact.reject(&:empty?)
57
+ ).merge(options)
58
+ self
59
+ end
60
+
61
+ def reset!
62
+ @settings = nil
63
+ KeyPool.reset!
64
+ Cache.clear!
65
+ end
66
+
67
+ def [](key)
68
+ settings[key]
69
+ end
70
+
71
+ # 按 provider (:baidu/:gaode/:tencent) 取可用 key 数组, 未配置时抛出清晰错误
72
+ def keys_for(provider)
73
+ field = PROVIDER_KEY_FIELD[provider]
74
+ raise ArgumentError, "未知的服务商: #{provider.inspect}" unless field
75
+
76
+ keys = settings[field]
77
+ raise ConfigurationError, "未配置 #{provider} 的 key (#{field}), 请通过 Coordconver.configure 或 Coordconver::Client.new 设置" if keys.empty?
78
+
79
+ keys
80
+ end
81
+
82
+ def method_missing(name, *args)
83
+ if name.to_s.end_with?("=") && settings.key?(name.to_s.chomp("=").to_sym)
84
+ settings[name.to_s.chomp("=").to_sym] = args.first
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def respond_to_missing?(name, include_private = false)
91
+ (name.to_s.end_with?("=") && settings.key?(name.to_s.chomp("=").to_sym)) || super
92
+ end
93
+
94
+ def settings
95
+ @settings or raise ConfigurationError, "尚未配置地图 API key。请先执行 Coordconver.configure { |c| c.baidu_map_aks = [...]; c.gaode_map_keys = [...] } 或 Coordconver::Client.new([百度ak数组], [高德key数组])"
96
+ end
97
+
98
+ # 兼容旧接口: 取一个 key (现为实现为轮询)
99
+ def sample_baidu_ak
100
+ KeyPool.next_key(:baidu, keys_for(:baidu))
101
+ end
102
+
103
+ def sample_gaode_key
104
+ KeyPool.next_key(:gaode, keys_for(:gaode))
105
+ end
106
+
107
+ def sample_tencent_key
108
+ KeyPool.next_key(:tencent, keys_for(:tencent))
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coordconver
4
+ module Geo
5
+ # 高德地图 Web 服务 API
6
+ # 申请 key: https://console.amap.com/dev/key/app (类型选"Web服务")
7
+ module Gaode
8
+ extend self
9
+
10
+ # 视为"key 无效/配额超限"的 infocode, 触发自动轮换下一个 key
11
+ # 10001 KEY不合法 10003 日调用量超限 10009 KEY类型错误 10044 超出支持范围 20003 日调用量超限
12
+ KEY_ERROR_INFOCODES = %w[10001 10003 10009 10044 20003].freeze
13
+
14
+ # 高德官方坐标转换接口支持的源坐标系
15
+ COORDSYS_MAP = {
16
+ gps: "gps", wgs84: "gps", wgs: "gps",
17
+ baidu: "baidu", bd09: "baidu", bd: "baidu",
18
+ gcj02: "autonavi", gcj: "autonavi", autonavi: "autonavi"
19
+ }.freeze
20
+
21
+ # 地理编码: 结构化地址 -> 坐标 (GCJ-02)
22
+ # Coordconver::Gaode.geo("北京方恒国际中心A座")["geocodes"][0]["location"] # => "116.480724,39.989584"
23
+ # 支持可选参数: city(城市名), citylimit, batch, ...
24
+ def geo(address, options = {})
25
+ params = { address: address }.merge(options)
26
+ request("/geocode/geo", params)
27
+ end
28
+
29
+ # 逆地理编码: 坐标 (GCJ-02) -> 结构化地址
30
+ # Coordconver::Gaode.regeo(116.480881, 39.989410)["regeocode"]["formatted_address"]
31
+ # options: extensions(base/all), radius, poitype, ...
32
+ def regeo(lng, lat, options = {})
33
+ params = { location: "#{lng},#{lat}" }.merge(options)
34
+ request("/geocode/regeo", params)
35
+ end
36
+
37
+ # 官方坐标转换 API: 将 GPS(WGS-84)/百度(BD-09) 坐标转换为高德坐标 (GCJ-02)。
38
+ # 与离线算法互为补充, 适合需要官方权威转换结果的场景。
39
+ # 单点或数组均可(最多 40 个点):
40
+ # Coordconver::Gaode.convert([121.468738, 31.247138], from: :gps)
41
+ # Coordconver::Gaode.convert([[121.46, 31.24], [116.39, 39.90]], from: :gps)
42
+ # 返回完整 JSON; from 支持 :gps(wgs84) / :baidu(bd09) / :gcj02
43
+ def convert(points, from: :gps)
44
+ coordsys = COORDSYS_MAP[from.to_s.downcase.to_sym]
45
+ raise ArgumentError, "不支持的源坐标系: #{from.inspect} (支持 :gps/:wgs84, :baidu/:bd09, :gcj02)" unless coordsys
46
+
47
+ list = normalize_point_list(points)
48
+ raise ArgumentError, "单次最多转换 40 个坐标点" if list.size > 40
49
+
50
+ params = {
51
+ locations: list.map { |pt| Coordconver.extract_pair(pt).join(",") }.join(";"),
52
+ coordsys: coordsys
53
+ }
54
+ request("/assistant/coordinate/convert", params)
55
+ end
56
+
57
+ # convert 的便捷版本, 返回 [[lng, lat], ...] (GCJ-02), 失败返回 nil
58
+ def convert_location(points, from: :gps)
59
+ resp = convert(points, from: from)
60
+ return nil unless resp["status"] == "1" && resp["locations"]
61
+
62
+ resp["locations"].split(";").map { |s| s.split(",").map(&:to_f) }
63
+ end
64
+
65
+ # 便捷方法: 地址 -> [lng, lat] (GCJ-02), 解析失败返回 nil
66
+ def geo_location(address, options = {})
67
+ resp = geo(address, options)
68
+ geocode = resp["geocodes"] && resp["geocodes"][0]
69
+ loc = geocode && geocode["location"]
70
+ return nil if loc.nil? || loc.empty?
71
+
72
+ loc.split(",").map(&:to_f)
73
+ end
74
+
75
+ # 便捷方法: 坐标 -> 地址字符串 (GCJ-02), 解析失败返回 nil
76
+ def regeo_address(lng, lat, options = {})
77
+ regeo(lng, lat, options).dig("regeocode", "formatted_address")
78
+ end
79
+
80
+ private
81
+
82
+ def normalize_point_list(points)
83
+ list = points.is_a?(Array) && points.first.is_a?(Numeric) ? [points] : Array(points)
84
+ raise ArgumentError, "未提供坐标点" if list.empty?
85
+
86
+ list
87
+ end
88
+
89
+ def request(path, params)
90
+ Api.call(
91
+ provider: :gaode,
92
+ url: base_url + path,
93
+ params: params,
94
+ key_param: :key,
95
+ bad_result: ->(r) { r["status"].to_s == "0" && KEY_ERROR_INFOCODES.include?(r["infocode"].to_s) }
96
+ )
97
+ end
98
+
99
+ def base_url
100
+ # 兼容旧版本以 /geocode 结尾的 base_url 配置
101
+ Configuration.settings[:gaode_base_url].sub(%r{/geocode/?\z}, "").sub(%r{/\z}, "")
102
+ end
103
+ end
104
+ end
105
+ end