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.
data/exe/coordconver ADDED
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # 坐标转换命令行工具
5
+ #
6
+ # coordconver wgs2gcj 121.468738 31.247138 # 单点坐标系转换
7
+ # coordconver dms 38.65777 104.08296 # 十进制度 -> 度分秒
8
+ # coordconver bearing 116.397 39.909 121.474 31.230 # 两点方位角(度)
9
+ # coordconver dest 116.397 39.909 153 1000 # 沿方位角偏移后的坐标
10
+ # coordconver gk 121.47 31.24 --zone3 # 高斯-克吕格正算
11
+ # cat points.txt | coordconver wgs2gcj # 批量: 每行 "lng,lat" 或 "lng lat"
12
+
13
+ require "optparse"
14
+ require "csv"
15
+ require "json"
16
+
17
+ lib = File.expand_path("../lib", __dir__)
18
+ $LOAD_PATH.unshift(lib) if File.exist?(File.join(lib, "coordconver.rb"))
19
+ require "coordconver"
20
+
21
+ CONVERSION_ALIASES = {
22
+ "wgs" => "wgs", "wgs84" => "wgs",
23
+ "gcj" => "gcj", "gcj02" => "gcj",
24
+ "bd" => "bd", "bd09" => "bd"
25
+ }.freeze
26
+
27
+ METHOD_MAP = {
28
+ %w[wgs gcj] => "wgs_gcj", %w[gcj wgs] => "gcj_wgs",
29
+ %w[wgs bd] => "wgs_bd", %w[bd wgs] => "bd_wgs",
30
+ %w[gcj bd] => "gcj_bd", %w[bd gcj] => "bd_gcj"
31
+ }.freeze
32
+
33
+ def usage_and_exit(message = nil)
34
+ warn message if message
35
+ warn "用法: coordconver <命令> [参数] (--help 查看完整帮助)"
36
+ exit 1
37
+ end
38
+
39
+ def parse_conversion(name)
40
+ match = name.to_s.match(%r{\A(wgs84|wgs|gcj02|gcj|bd09|bd)\s*(?:2|to|->|=>|_)\s*(wgs84|wgs|gcj02|gcj|bd09|bd)\z}i)
41
+ usage_and_exit("无法识别的转换名称: #{name}") unless match
42
+
43
+ from = CONVERSION_ALIASES[match[1].downcase]
44
+ to = CONVERSION_ALIASES[match[2].downcase]
45
+ usage_and_exit("源坐标系与目标坐标系相同") if from == to
46
+
47
+ METHOD_MAP[[from, to]]
48
+ end
49
+
50
+ def parse_coords(args)
51
+ coords = args.flat_map { |a| a.to_s.split(/[,\s]+/).reject(&:empty?).map(&:to_f) }
52
+ usage_and_exit("坐标参数不足, 需要 <lng> <lat>") if coords.size < 2
53
+ coords
54
+ end
55
+
56
+ def line_coords(line, csv_mode)
57
+ if csv_mode
58
+ row = CSV.parse_line(line)
59
+ return [] unless row
60
+
61
+ nums = row.select { |c| c && c.match?(/\A[+-]?\d+(\.\d+)?\z/) }
62
+ nums.map(&:to_f)
63
+ else
64
+ line.split(/[,\s]+/).reject(&:empty?).map(&:to_f)
65
+ end
66
+ end
67
+
68
+ options = { round: 6, csv: false }
69
+ parser = OptionParser.new do |opts|
70
+ opts.banner = "coordconver #{Coordconver::VERSION} — 坐标转换命令行工具"
71
+
72
+ opts.separator ""
73
+ opts.separator "坐标系转换 (WGS-84 地球 / GCJ-02 火星 / BD-09 百度):"
74
+ opts.separator " coordconver <转换名> <lng> <lat> 转换名如 wgs2gcj, gcj2bd, wgs84togcj02"
75
+
76
+ opts.separator ""
77
+ opts.separator "其他命令:"
78
+ opts.separator " dms <lng> <lat> 十进制度转度分秒"
79
+ opts.separator " dec <度分秒> <度分秒> 度分秒转十进制度"
80
+ opts.separator " dist <lng1> <lat1> <lng2> <lat2> 两点球面距离(米)"
81
+ opts.separator " bearing <lng1> <lat1> <lng2> <lat2> 两点初始方位角(度, 正北为0)"
82
+ opts.separator " dest <lng> <lat> <方位角> <距离米> 沿方位角偏移后的坐标"
83
+ opts.separator " gk <lng> <lat> 高斯-克吕格正算 -> [东坐标, 北坐标]"
84
+ opts.separator " invgk <x> <y> --cm 中央经线 高斯-克吕格反算"
85
+ opts.separator " geo <地址> --baidu|--gaode|--tencent 地址转坐标(需 API key)"
86
+ opts.separator " regeo <lng> <lat> --baidu|--gaode|--tencent 坐标转地址(需 API key)"
87
+ opts.separator ""
88
+ opts.separator "批量模式: 未给坐标参数时从 stdin 读取, 每行一条 \"lng,lat\" 或 \"lng lat\""
89
+
90
+ opts.separator ""
91
+ opts.separator "选项:"
92
+ opts.on("--baidu", "geo/regeo 使用百度地图 API") { options[:provider] = "baidu" }
93
+ opts.on("--gaode", "geo/regeo 使用高德地图 API (默认)") { options[:provider] = "gaode" }
94
+ opts.on("--tencent", "geo/regeo 使用腾讯地图 API") { options[:provider] = "tencent" }
95
+ opts.on("--cache", "geo/regeo 启用进程内结果缓存") { options[:cache] = true }
96
+ opts.on("--csv", "批量输入按 CSV 行解析(自动跳过表头)") { options[:csv] = true }
97
+ opts.on("-j", "--json", "以 JSON 数组输出") { options[:json] = true }
98
+ opts.on("--geojson", "转换结果以 GeoJSON 输出") { options[:geojson] = true }
99
+ opts.on("-r", "--round N", Integer, "小数位数, 默认 6") { |v| options[:round] = v }
100
+ opts.separator ""
101
+ opts.separator "高斯-克吕格选项:"
102
+ opts.on("--cm 度", Float, "中央经线(gk 可选, invgk 必填)") { |v| options[:cm] = v }
103
+ opts.on("--zone3", "按 3° 带推算中央经线(默认 6° 带)") { options[:zone_width] = 3 }
104
+ opts.on("--zone6", "按 6° 带推算中央经线") { options[:zone_width] = 6 }
105
+ opts.on("--ellipsoid 名称", "cgcs2000(默认)/wgs84/krassovsky/iag75") { |v| options[:ellipsoid] = v }
106
+ opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
107
+ opts.on("-v", "--version", "显示版本") { puts "coordconver #{Coordconver::VERSION}"; exit 0 }
108
+ end
109
+
110
+ parser.parse!(ARGV)
111
+
112
+ def configure_from_env(provider)
113
+ Coordconver.configure do |c|
114
+ if provider == "baidu" && ENV["COORDCONVER_BAIDU_AK"]
115
+ c.baidu_map_aks = ENV["COORDCONVER_BAIDU_AK"].split(",").map(&:strip)
116
+ elsif provider == "gaode" && ENV["COORDCONVER_GAODE_KEY"]
117
+ c.gaode_map_keys = ENV["COORDCONVER_GAODE_KEY"].split(",").map(&:strip)
118
+ elsif provider == "tencent" && ENV["COORDCONVER_TENCENT_KEY"]
119
+ c.tencent_map_keys = ENV["COORDCONVER_TENCENT_KEY"].split(",").map(&:strip)
120
+ end
121
+ end
122
+ end
123
+
124
+ def emit(result, options)
125
+ rounded = result.map { |v| v.is_a?(Numeric) ? v.round(options[:round]) : v }
126
+ options[:json] ? puts(JSON.generate(rounded)) : puts(rounded.join(","))
127
+ end
128
+
129
+ def emit_features(coords_list, options)
130
+ features = coords_list.map do |lng, lat|
131
+ { "type" => "Feature",
132
+ "geometry" => Coordconver.to_geojson(lng, lat),
133
+ "properties" => {} }
134
+ end
135
+ if features.size == 1
136
+ puts JSON.generate(features[0])
137
+ else
138
+ puts JSON.generate({ "type" => "FeatureCollection", "features" => features })
139
+ end
140
+ end
141
+
142
+ command = ARGV.shift
143
+ usage_and_exit if command.nil?
144
+
145
+ begin
146
+ case command.downcase
147
+ when "dms"
148
+ usage_and_exit("dms 需要 <lng> <lat> 两个参数") if ARGV.size < 2
149
+ emit(Coordconver.degree(Float(ARGV[0]), Float(ARGV[1])), options)
150
+ when "dec"
151
+ usage_and_exit("dec 需要 <度分秒> <度分秒> 两个参数") if ARGV.size < 2
152
+ emit(Coordconver.undegree(ARGV[0], ARGV[1]), options)
153
+ when "dist"
154
+ usage_and_exit("dist 需要 <lng1> <lat1> <lng2> <lat2> 四个参数") if ARGV.size < 4
155
+ puts Coordconver.distance(Float(ARGV[0]), Float(ARGV[1]), Float(ARGV[2]), Float(ARGV[3]))
156
+ when "bearing"
157
+ usage_and_exit("bearing 需要 <lng1> <lat1> <lng2> <lat2> 四个参数") if ARGV.size < 4
158
+ puts Coordconver.bearing(Float(ARGV[0]), Float(ARGV[1]), Float(ARGV[2]), Float(ARGV[3])).round(2)
159
+ when "dest"
160
+ usage_and_exit("dest 需要 <lng> <lat> <方位角> <距离米> 四个参数") if ARGV.size < 4
161
+ result = Coordconver.destination_point(Float(ARGV[0]), Float(ARGV[1]), Float(ARGV[2]), Float(ARGV[3]))
162
+ emit(result, options)
163
+ when "gk", "invgk"
164
+ if command.downcase == "gk"
165
+ usage_and_exit("gk 需要 <lng> <lat> 两个参数") if ARGV.size < 2
166
+ result = Coordconver.gauss_kruger(
167
+ Float(ARGV[0]), Float(ARGV[1]),
168
+ central_meridian: options[:cm],
169
+ zone_width: options[:zone_width] || 6,
170
+ ellipsoid: options[:ellipsoid] || :cgcs2000
171
+ )
172
+ emit(result, { round: 2 })
173
+ else
174
+ usage_and_exit("invgk 需要 <x> <y> 并指定 --cm 中央经线") if ARGV.size < 2 || options[:cm].nil?
175
+ result = Coordconver.inverse_gauss_kruger(
176
+ Float(ARGV[0]), Float(ARGV[1]),
177
+ central_meridian: options[:cm],
178
+ ellipsoid: options[:ellipsoid] || :cgcs2000
179
+ )
180
+ emit(result, options)
181
+ end
182
+ when "geo"
183
+ provider = options[:provider] || "gaode"
184
+ usage_and_exit("geo 需要 <地址> 参数") if ARGV.empty?
185
+ configure_from_env(provider)
186
+ Coordconver.configure { |c| c.cache = true } if options[:cache]
187
+ location = {
188
+ "baidu" => -> { Coordconver::Baidu.geo_location(ARGV[0]) },
189
+ "gaode" => -> { Coordconver::Gaode.geo_location(ARGV[0]) },
190
+ "tencent" => -> { Coordconver::Tencent.geo_location(ARGV[0]) }
191
+ }[provider].call
192
+ if location
193
+ emit(location, options)
194
+ else
195
+ warn "解析失败: #{ARGV[0].inspect}"
196
+ exit 2
197
+ end
198
+ when "regeo"
199
+ provider = options[:provider] || "gaode"
200
+ usage_and_exit("regeo 需要 <lng> <lat> 参数") if ARGV.size < 2
201
+ configure_from_env(provider)
202
+ Coordconver.configure { |c| c.cache = true } if options[:cache]
203
+ address = {
204
+ "baidu" => -> { Coordconver::Baidu.regeo_address(Float(ARGV[0]), Float(ARGV[1])) },
205
+ "gaode" => -> { Coordconver::Gaode.regeo_address(Float(ARGV[0]), Float(ARGV[1])) },
206
+ "tencent" => -> { Coordconver::Tencent.regeo_address(Float(ARGV[0]), Float(ARGV[1])) }
207
+ }[provider].call
208
+ if address
209
+ puts address
210
+ else
211
+ warn "解析失败: #{ARGV[0]},#{ARGV[1]}"
212
+ exit 2
213
+ end
214
+ else
215
+ method = parse_conversion(command)
216
+ if ARGV.empty?
217
+ usage_and_exit("需要 <lng> <lat> 参数, 或通过 stdin 管道输入批量坐标") if $stdin.tty?
218
+
219
+ converted = []
220
+ $stdin.each_line do |line|
221
+ coords = line_coords(line, options[:csv])
222
+ next if coords.size < 2
223
+
224
+ pair = Coordconver.public_send(method, coords[0], coords[1])
225
+ if options[:geojson]
226
+ converted << pair
227
+ else
228
+ emit(pair, options)
229
+ end
230
+ end
231
+ emit_features(converted, options) if options[:geojson]
232
+ else
233
+ coords = parse_coords(ARGV)
234
+ pair = Coordconver.public_send(method, coords[0], coords[1])
235
+ options[:geojson] ? emit_features([pair], options) : emit(pair, options)
236
+ end
237
+ end
238
+ rescue Coordconver::Error, ArgumentError, TypeError, CSV::MalformedCSVError => e
239
+ warn "错误: #{e.message}"
240
+ exit 2
241
+ end
@@ -1,106 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 坐标系互转核心算法(纯离线计算)。
4
+ #
5
+ # BD-09 百度坐标系, 百度地图使用
6
+ # GCJ-02 火星坐标系(国测局), 高德/腾讯/谷歌中国使用
7
+ # WGS-84 地球坐标系, GPS 设备原始坐标
1
8
  module Coordconver
2
- module Coordconver
3
-
4
- def self.included(base)
5
- base.extend(ClassMethods)
6
- end
7
-
8
- # 定义一些常量
9
- X_PI = 3.14159265358979324 * 3000.0 / 180.0
10
- PI = 3.1415926535897932384626
11
- AA = 6378245.0 #a
12
- EE = 0.00669342162296594323 #e
13
-
14
- module ClassMethods
15
-
16
- def transformlat(lng, lat)
17
- ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * Math.sqrt((lng).abs);
18
- ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
19
- ret += (20.0 * Math.sin(lat * PI) + 40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0;
20
- ret += (160.0 * Math.sin(lat / 12.0 * PI) + 320 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0;
21
- return ret
22
- end
23
-
24
- def transformlng(lng, lat)
25
- ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt((lng).abs);
26
- ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0;
27
- ret += (20.0 * Math.sin(lng * PI) + 40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0;
28
- ret += (150.0 * Math.sin(lng / 12.0 * PI) + 300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0;
29
- end
30
-
31
- # 百度坐标系 (BD-09) -> 火星坐标系 (GCJ-02)
32
- # 即 百度 -> 谷歌、高德
33
- # Coordconver.bd_gcj(lng, lat)
34
- def bd_gcj(lng, lat)
35
- x = lng - 0.0065;
36
- y = lat - 0.006;
37
- z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI);
38
- theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI);
39
- gg_lng = z * Math.cos(theta);
40
- gg_lat = z * Math.sin(theta);
41
- return [gg_lng, gg_lat]
42
- end
43
-
44
- # 百度坐标系 (BD-09) -> 地球坐标 (WGS-84)
45
- # 即 百度 -> 地球坐标
46
- # Coordconver.bd_wgs(lng, lat)
47
- def bd_wgs(lng, lat)
48
- loca = wgs_gcj(lng, lat)
49
- gcj_bd(loca[0], loca[1])
50
- end
51
-
52
- # 火星坐标系 (GCJ02) -> 地球坐标 (WGS-84)
53
- # 即 谷歌、高德 -> 地球坐标
54
- # Coordconver.gcj_wgs(lng, lat)
55
- def gcj_wgs(lng, lat)
56
- dlat = transformlat(lng - 105.0, lat - 35.0);
57
- dlng = transformlng(lng - 105.0, lat - 35.0);
58
- radlat = lat / 180.0 * PI;
59
- magic = Math.sin(radlat);
60
- magic = 1 - EE * magic * magic;
61
- sqrtmagic = Math.sqrt(magic);
62
- dlat = (dlat * 180.0) / ((AA * (1 - EE)) / (magic * sqrtmagic) * PI);
63
- dlng = (dlng * 180.0) / (AA / sqrtmagic * Math.cos(radlat) * PI);
64
- mglat = lat + dlat;
65
- mglng = lng + dlng;
66
- return [lng * 2 - mglng, lat * 2 - mglat]
67
- end
68
-
69
- # 火星坐标系 (GCJ-02) 与百度坐标系 (BD-09)
70
- # 即 谷歌、高德 -> 百度
71
- # Coordconver.gcj_bd(lng, lat)
72
- def gcj_bd(lng, lat)
73
- z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * X_PI);
74
- theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * X_PI);
75
- bd_lng = z * Math.cos(theta) + 0.0065;
76
- bd_lat = z * Math.sin(theta) + 0.006;
77
- return [bd_lng, bd_lat]
78
- end
79
-
80
- # 地球坐标 ( WGS-84) 与百度坐标系 (BD-09)
81
- # 即 地球坐标 -> 百度
82
- # Coordconver.wgs_bd(lng, lat)
83
- def wgs_bd(lng, lat)
84
- loca = wgs_gcj(lng, lat)
85
- gcj_bd(loca[0], loca[1])
86
- end
87
-
88
- # 地球坐标 ( WGS-84) 与火星坐标系 (GCJ-02)
89
- # 即 地球坐标 -> 谷歌、高德
90
- # Coordconver.wgs_gcj(lng, lat)
91
- def wgs_gcj(lng, lat)
92
- dlat = transformlat(lng - 105.0, lat - 35.0);
93
- dlng = transformlng(lng - 105.0, lat - 35.0);
94
- radlat = lat / 180.0 * PI;
95
- magic = Math.sin(radlat);
96
- magic = 1 - EE * magic * magic;
97
- sqrtmagic = Math.sqrt(magic);
98
- dlat = (dlat * 180.0) / ((AA * (1 - EE)) / (magic * sqrtmagic) * PI);
99
- dlng = (dlng * 180.0) / (AA / sqrtmagic * Math.cos(radlat) * PI);
100
- mglat = lat + dlat;
101
- mglng = lng + dlng;
102
- return [mglng, mglat]
103
- end
104
- end
105
- end
9
+ X_PI = 3.14159265358979324 * 3000.0 / 180.0
10
+ PI = 3.1415926535897932384626
11
+ # 克拉索夫斯基椭球参数(GCJ-02 偏移算法使用)
12
+ SEMI_MAJOR_AXIS = 6378245.0
13
+ ECCENTRICITY_SQUARED = 0.00669342162296594323
14
+ # 平均地球半径(米), 球面距离/方位角计算使用
15
+ EARTH_RADIUS = 6_371_008.8
16
+ # 精确逆变换迭代次数
17
+ PRECISE_ITERATIONS = 5
18
+ # 中国区域大致边界。GCJ-02 加密仅在中国境内生效, 境外 WGS-84 与 GCJ-02 一致
19
+ CHINA_LNG_RANGE = [72.004, 137.8347].freeze
20
+ CHINA_LAT_RANGE = [0.8293, 55.8271].freeze
21
+
22
+ # 轻量坐标点结构, 可直接传给转换方法:
23
+ # Coordconver.wgs_gcj(Coordconver.point(121.47, 31.25))
24
+ Point = Struct.new(:lng, :lat, keyword_init: true) do
25
+ def to_a
26
+ [lng, lat]
27
+ end
28
+
29
+ def to_h
30
+ { lng: lng, lat: lat }
31
+ end
32
+ end
33
+
34
+ class << self
35
+ # 构造 Point 结构
36
+ def point(lng, lat)
37
+ Point.new(lng: lng.to_f, lat: lat.to_f)
38
+ end
39
+
40
+ # 判断坐标是否在中国境外
41
+ # Coordconver.out_of_china?(121.47, 31.25) # => false
42
+ def out_of_china?(lng, lat)
43
+ lng < CHINA_LNG_RANGE[0] || lng > CHINA_LNG_RANGE[1] ||
44
+ lat < CHINA_LAT_RANGE[0] || lat > CHINA_LAT_RANGE[1]
45
+ end
46
+
47
+ # ---------- 坐标系转换 ----------
48
+ # 所有方法支持三种入参: (lng, lat) 双参 / Point / [lng, lat] / {lng:, lat:}
49
+ # 并支持 as: :hash 返回 {lng:, lat:}, as: :point 返回 Point, 默认 [lng, lat]
50
+
51
+ # 百度坐标系 (BD-09) -> 火星坐标系 (GCJ-02)
52
+ # 即 百度 -> 谷歌、高德、腾讯
53
+ # Coordconver.bd_gcj(121.47981626441, 31.251161076123) # => [121.47328, 31.24522]
54
+ def bd_gcj(lng, lat = nil, as: nil)
55
+ a, b = extract_pair(lng, lat)
56
+ x = a - 0.0065
57
+ y = b - 0.006
58
+ z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI)
59
+ theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI)
60
+ fmt([z * Math.cos(theta), z * Math.sin(theta)], as)
61
+ end
62
+
63
+ # 百度坐标系 (BD-09) -> 地球坐标 (WGS-84)
64
+ # 即 百度 -> 地球坐标 (近似, 误差米级)
65
+ def bd_wgs(lng, lat = nil, as: nil)
66
+ a, b = extract_pair(lng, lat)
67
+ fmt(gcj_wgs_raw(*bd_gcj_raw(a, b)), as)
68
+ end
69
+
70
+ # 百度坐标系 (BD-09) -> 地球坐标 (WGS-84), 迭代精确解, 厘米级
71
+ def bd_wgs_precise(lng, lat = nil, as: nil)
72
+ a, b = extract_pair(lng, lat)
73
+ fmt(gcj_wgs_iterate(*bd_gcj_raw(a, b)), as)
74
+ end
75
+
76
+ # 火星坐标系 (GCJ-02) -> 地球坐标 (WGS-84)
77
+ # 即 谷歌、高德、腾讯 -> 地球坐标。境外坐标原样返回。(近似, 误差米级)
78
+ def gcj_wgs(lng, lat = nil, as: nil)
79
+ a, b = extract_pair(lng, lat)
80
+ fmt(gcj_wgs_raw(a, b), as)
81
+ end
82
+
83
+ # 火星坐标系 (GCJ-02) -> 地球坐标 (WGS-84), 迭代精确解, 厘米级
84
+ # Coordconver.gcj_wgs_precise(*Coordconver.wgs_gcj(121.47, 31.24)) # => [121.47, 31.24]
85
+ def gcj_wgs_precise(lng, lat = nil, as: nil)
86
+ a, b = extract_pair(lng, lat)
87
+ fmt(gcj_wgs_iterate(a, b), as)
88
+ end
89
+
90
+ # 火星坐标系 (GCJ-02) -> 百度坐标系 (BD-09)
91
+ # 即 谷歌、高德、腾讯 -> 百度
92
+ def gcj_bd(lng, lat = nil, as: nil)
93
+ a, b = extract_pair(lng, lat)
94
+ z = Math.sqrt(a * a + b * b) + 0.00002 * Math.sin(b * X_PI)
95
+ theta = Math.atan2(b, a) + 0.000003 * Math.cos(a * X_PI)
96
+ fmt([z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006], as)
97
+ end
98
+
99
+ # 地球坐标 (WGS-84) -> 百度坐标系 (BD-09)
100
+ # 即 地球坐标 -> 百度
101
+ def wgs_bd(lng, lat = nil, as: nil)
102
+ a, b = extract_pair(lng, lat)
103
+ fmt(gcj_bd_raw(*wgs_gcj_raw(a, b)), as)
104
+ end
105
+
106
+ # 地球坐标 (WGS-84) -> 火星坐标系 (GCJ-02)
107
+ # 即 地球坐标 -> 谷歌、高德、腾讯。境外坐标原样返回
108
+ def wgs_gcj(lng, lat = nil, as: nil)
109
+ a, b = extract_pair(lng, lat)
110
+ fmt(wgs_gcj_raw(a, b), as)
111
+ end
112
+
113
+ # ---------- 批量转换 ----------
114
+ # 支持数组/Point/Hash 混合输入:
115
+ # Coordconver.transform([[121.46, 31.24], {lng: 116.39, lat: 39.90}], from: :wgs84, to: :gcj02)
116
+ # from/to 支持 :wgs84(wgs) :gcj02(gcj) :bd09(bd)
117
+ def transform(points, from:, to:, as: nil)
118
+ method = transform_method(from, to)
119
+ points.map do |pt|
120
+ a, b = extract_pair(pt)
121
+ fmt(public_send(method, a, b), as)
122
+ end
123
+ end
124
+
125
+ # ---------- 球面几何 ----------
126
+
127
+ # 两点球面距离(Haversine 公式), 返回米。两点需在同一坐标系下。
128
+ # Coordconver.distance(116.397428, 39.90923, 121.473701, 31.230416) # => 约 106.8 万米
129
+ def distance(lng1, lat1, lng2, lat2)
130
+ dlng = to_radian(lng2 - lng1)
131
+ dlat = to_radian(lat2 - lat1)
132
+ a = Math.sin(dlat / 2)**2 +
133
+ Math.cos(to_radian(lat1)) * Math.cos(to_radian(lat2)) * Math.sin(dlng / 2)**2
134
+ (2 * EARTH_RADIUS * Math.asin(Math.sqrt(a))).round(1)
135
+ end
136
+
137
+ # 起点到终点的初始方位角(以正北为 0°, 顺时针 0-360°)
138
+ # Coordconver.bearing(116.397428, 39.90923, 121.473701, 31.230416) # => 约 137.5
139
+ def bearing(lng1, lat1, lng2, lat2)
140
+ p1 = to_radian(lat1)
141
+ p2 = to_radian(lat2)
142
+ dl = to_radian(lng2 - lng1)
143
+ y = Math.sin(dl) * Math.cos(p2)
144
+ x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl)
145
+ ((Math.atan2(y, x) * 180.0 / PI) + 360) % 360
146
+ end
147
+
148
+ # 从起点沿方位角移动指定距离(米)后的坐标
149
+ # Coordconver.destination_point(116.397428, 39.90923, 137.5, 1_068_000)
150
+ def destination_point(lng, lat, bearing_deg, distance_m, as: nil)
151
+ d = distance_m.to_f / EARTH_RADIUS
152
+ p1 = to_radian(lat)
153
+ b = to_radian(bearing_deg)
154
+ p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(b))
155
+ l2 = to_radian(lng) + Math.atan2(
156
+ Math.sin(b) * Math.sin(d) * Math.cos(p1),
157
+ Math.cos(d) - Math.sin(p1) * Math.sin(p2)
158
+ )
159
+ lon = ((l2 * 180.0 / PI) + 540) % 360 - 180
160
+ fmt([lon, p2 * 180.0 / PI], as)
161
+ end
162
+
163
+ # ---------- 兼容旧式/语义化别名 ----------
164
+ alias wgs84_gcj02 wgs_gcj
165
+ alias gcj02_wgs84 gcj_wgs
166
+ alias gcj02_bd09 gcj_bd
167
+ alias bd09_gcj02 bd_gcj
168
+ alias wgs84_bd09 wgs_bd
169
+ alias bd09_wgs84 bd_wgs
170
+ alias gcj02_wgs84_precise gcj_wgs_precise
171
+ alias bd09_wgs84_precise bd_wgs_precise
172
+
173
+ # 归一化坐标入参: 支持 (lng, lat) 双参 / Point / [lng, lat] / {lng:, lat:}
174
+ def extract_pair(lng, lat = nil)
175
+ return [lng.to_f, lat.to_f] unless lat.nil?
176
+
177
+ case lng
178
+ when Point then [lng.lng.to_f, lng.lat.to_f]
179
+ when Hash
180
+ [float_of(lng[:lng] || lng["lng"]), float_of(lng[:lat] || lng["lat"])]
181
+ when Array then [float_of(lng[0]), float_of(lng[1])]
182
+ else raise ArgumentError, "无法解析的坐标: #{lng.inspect} (支持 (lng, lat) / Point / [lng, lat] / {lng:, lat:})"
183
+ end
184
+ end
185
+
186
+ private
187
+
188
+ def float_of(v)
189
+ raise ArgumentError, "坐标值无效: #{v.inspect}" if v.nil?
190
+
191
+ Float(v)
192
+ end
193
+
194
+ def fmt(pair, as)
195
+ case as
196
+ when nil then pair
197
+ when :hash then { lng: pair[0], lat: pair[1] }
198
+ when :point then Point.new(lng: pair[0], lat: pair[1])
199
+ else raise ArgumentError, "未知的返回格式: #{as.inspect} (支持 :hash / :point)"
200
+ end
201
+ end
202
+
203
+ def transform_method(from, to)
204
+ norm = ->(s) { s.to_s.downcase.gsub(/(84|02|09)\z/, "") }
205
+ map = {
206
+ %w[wgs gcj] => :wgs_gcj, %w[gcj wgs] => :gcj_wgs,
207
+ %w[wgs bd] => :wgs_bd, %w[bd wgs] => :bd_wgs,
208
+ %w[gcj bd] => :gcj_bd, %w[bd gcj] => :bd_gcj
209
+ }
210
+ m = map[[norm.call(from), norm.call(to)]]
211
+ raise ArgumentError, "不支持的转换: #{from} -> #{to}" unless m
212
+
213
+ m
214
+ end
215
+
216
+ def to_radian(deg)
217
+ deg * PI / 180.0
218
+ end
219
+
220
+ # gcj_wgs 迭代精确解: 解方程 wgs_gcj(w) = g
221
+ def gcj_wgs_iterate(lng, lat)
222
+ w = [lng, lat]
223
+ PRECISE_ITERATIONS.times do
224
+ cur = wgs_gcj_raw(w[0], w[1])
225
+ w = [w[0] + (lng - cur[0]), w[1] + (lat - cur[1])]
226
+ end
227
+ w
228
+ end
229
+
230
+ def gcj_wgs_raw(lng, lat)
231
+ return [lng, lat] if out_of_china?(lng, lat)
232
+
233
+ dlat = transformlat(lng - 105.0, lat - 35.0)
234
+ dlng = transformlng(lng - 105.0, lat - 35.0)
235
+ radlat = lat / 180.0 * PI
236
+ magic = 1 - ECCENTRICITY_SQUARED * Math.sin(radlat) * Math.sin(radlat)
237
+ sqrtmagic = Math.sqrt(magic)
238
+ dlat = (dlat * 180.0) / ((SEMI_MAJOR_AXIS * (1 - ECCENTRICITY_SQUARED)) / (magic * sqrtmagic) * PI)
239
+ dlng = (dlng * 180.0) / (SEMI_MAJOR_AXIS / sqrtmagic * Math.cos(radlat) * PI)
240
+ mglat = lat + dlat
241
+ mglng = lng + dlng
242
+ [lng * 2 - mglng, lat * 2 - mglat]
243
+ end
244
+
245
+ def wgs_gcj_raw(lng, lat)
246
+ return [lng, lat] if out_of_china?(lng, lat)
247
+
248
+ dlat = transformlat(lng - 105.0, lat - 35.0)
249
+ dlng = transformlng(lng - 105.0, lat - 35.0)
250
+ radlat = lat / 180.0 * PI
251
+ magic = 1 - ECCENTRICITY_SQUARED * Math.sin(radlat) * Math.sin(radlat)
252
+ sqrtmagic = Math.sqrt(magic)
253
+ dlat = (dlat * 180.0) / ((SEMI_MAJOR_AXIS * (1 - ECCENTRICITY_SQUARED)) / (magic * sqrtmagic) * PI)
254
+ dlng = (dlng * 180.0) / (SEMI_MAJOR_AXIS / sqrtmagic * Math.cos(radlat) * PI)
255
+ [lng + dlng, lat + dlat]
256
+ end
257
+
258
+ def gcj_bd_raw(lng, lat)
259
+ z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * X_PI)
260
+ theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * X_PI)
261
+ [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006]
262
+ end
263
+
264
+ def bd_gcj_raw(lng, lat)
265
+ x = lng - 0.0065
266
+ y = lat - 0.006
267
+ z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI)
268
+ theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI)
269
+ [z * Math.cos(theta), z * Math.sin(theta)]
270
+ end
271
+
272
+ def transformlat(lng, lat)
273
+ ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * Math.sqrt(lng.abs)
274
+ ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0
275
+ ret += (20.0 * Math.sin(lat * PI) + 40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0
276
+ ret += (160.0 * Math.sin(lat / 12.0 * PI) + 320 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0
277
+ ret
278
+ end
279
+
280
+ def transformlng(lng, lat)
281
+ ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt(lng.abs)
282
+ ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0
283
+ ret += (20.0 * Math.sin(lng * PI) + 40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0
284
+ ret += (150.0 * Math.sin(lng / 12.0 * PI) + 300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0
285
+ ret
286
+ end
287
+ end
106
288
  end