git-fit 0.1.1 → 0.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9551667addc842c5ddb8357d32b9d4eb040eedc236eeb3c940f59ea52f8be329
4
- data.tar.gz: 3ede4c5df18fd6a3380b5aff8845bf2ac1ca7d1074c2507e647e05363bf745fb
3
+ metadata.gz: a3c36d045a4ed37a0eaad4f219367f1d514af31c26bcae72e76cbeec7bb5dbb0
4
+ data.tar.gz: ab845ce910206dae48a131315427bf06604bd02988b097eb462ae5805727ab97
5
5
  SHA512:
6
- metadata.gz: 437969ed9c1dd8ec94de2449cbe8e71e8201bcfe2299fd3dcdcf44c8ae6b4901482b99fdd3459482395896a10c96c455a1acff8c69fd2e242c81e1befdb88941
7
- data.tar.gz: 11ffe29a5aecb4c305f361c81ad3eef6f73394fc9abb00f75486d68a32caac3de565ef941236120622b63c61059b20486f790e32290ef016da93141266d20b5e
6
+ metadata.gz: 9822b621a741ce52c68c6510f6f73a1da52ae544c4371d05ffe430cacd1d882a914f4bd6959b98add49a13595b0ffc703b4258fd5dd5a6535ad887117d5e8be0
7
+ data.tar.gz: 139da861fd411de27553e13e03ec0b3c7d94e5019c64e3e6ba820415e49f87f43ed58490565c408801554f30c3b0a2fa03133e48c4d783ffa7276b54e9f67e22
data/lib/git-fit.rb CHANGED
@@ -3,6 +3,8 @@ require "yaml"
3
3
  require "fileutils"
4
4
 
5
5
  module GitFit
6
+ SPORT_CATEGORIES = %w[run ride hike walk swim ski workout other].freeze
7
+
6
8
  @registered_configs = []
7
9
 
8
10
  class << self
@@ -11,12 +13,52 @@ module GitFit
11
13
  def register_config_source(prefix:, keys:, config_path:)
12
14
  @registered_configs << { prefix: prefix, keys: keys, config_path: config_path }
13
15
  end
16
+
17
+ def activity_to_hash(a)
18
+ return nil unless a
19
+
20
+ {
21
+ run_id: a[:run_id],
22
+ name: a[:name],
23
+ distance: a[:distance],
24
+ moving_time: format_time(a[:moving_time]),
25
+ type: a[:sport_category],
26
+ subtype: a[:sport_type],
27
+ start_date: a[:start_date],
28
+ start_date_local: a[:start_date_local],
29
+ location_country: a[:location_country],
30
+ summary_polyline: a[:summary_polyline],
31
+ average_heartrate: a[:average_heartrate],
32
+ average_speed: a[:average_speed],
33
+ elevation_gain: a[:elevation_gain]
34
+ }
35
+ end
36
+
37
+ def format_time(seconds)
38
+ return nil unless seconds
39
+ hours = seconds / 3600
40
+ minutes = (seconds % 3600) / 60
41
+ secs = seconds % 60
42
+ format("%d:%02d:%02d", hours, minutes, secs)
43
+ end
14
44
  end
15
45
  end
16
46
 
17
47
  require_relative "git_fit/version"
18
48
  require_relative "git_fit/config"
19
49
  require_relative "git_fit/config_template"
50
+ require_relative "git_fit/sport_mapper"
51
+ require_relative "git_fit/geo"
52
+ require_relative "git_fit/fit"
53
+ require_relative "git_fit/parser"
54
+ require_relative "git_fit/source"
55
+ require_relative "git_fit/timezone/resolver"
56
+ require_relative "git_fit/privacy/polyline_filter"
20
57
  require_relative "git_fit/sync/base"
21
58
  require_relative "git_fit/sync/strava"
59
+ require_relative "git_fit/db/connection"
60
+ require_relative "git_fit/db/activity"
61
+ require_relative "git_fit/export"
62
+ require_relative "git_fit/db/cli"
63
+ require_relative "git_fit/cli/export"
22
64
  require_relative "git_fit/cli"
@@ -0,0 +1,25 @@
1
+ require "thor"
2
+
3
+ module GitFit
4
+ class ExportCLI < Thor
5
+ desc "json", "Export activities to JSON"
6
+ option :output, type: :string, aliases: "-o", desc: "Output path"
7
+ option :activity, type: :array, desc: "Filter by sport type", aliases: "--act"
8
+ no_commands do
9
+ def git_fit_config
10
+ @git_fit_config ||= GitFit::Config.new(options[:config])
11
+ end
12
+ end
13
+
14
+ def json
15
+ config = git_fit_config
16
+ db = GitFit::DB::Connection.new(config.db_path).db
17
+ path = options[:output] || config.export_config.dig("json", "path") || "site/activities.json"
18
+ filter = options[:activity]&.map(&:strip)&.map(&:downcase)
19
+ count = Export::JSON.new(db: db, output: path, activity_filter: filter).call
20
+ say_status :done, "Exported #{count} activities to #{path}", :green
21
+ rescue => e
22
+ say_status :error, "JSON export failed: #{e.message}", :red
23
+ end
24
+ end
25
+ end
data/lib/git_fit/cli.rb CHANGED
@@ -10,11 +10,54 @@ module GitFit
10
10
  true
11
11
  end
12
12
 
13
+ desc "db SUBCOMMAND", "Database operations"
14
+ subcommand "db", DB::CLI
15
+
16
+ desc "export SUBCOMMAND", "Export activities to various formats"
17
+ subcommand "export", ExportCLI
18
+
13
19
  desc "version", "Show version"
14
20
  def version
15
21
  puts "git-fit v#{GitFit::VERSION}"
16
22
  end
17
23
 
24
+ desc "parse FILE", "Parse a GPX or TCX file"
25
+ option :save, type: :boolean, desc: "Save parsed activities to database", aliases: "-s"
26
+ def parse(file)
27
+ format = file.match?(/\.gpx$/i) ? :gpx : :tcx
28
+ content = File.read(file)
29
+ parser = format == :gpx ? GitFit::Parser::GPX.new(source: "file") : GitFit::Parser::TCX.new(source: "file")
30
+ activities = parser.call(content)
31
+
32
+ if activities.empty?
33
+ say_status :warn, "No activities found in #{file}", :yellow
34
+ return
35
+ end
36
+
37
+ activities.each do |act|
38
+ say_status :activity, act[:name] || "unnamed", :green
39
+ say " #{act[:sport_category]}#{act[:sport_type] ? " (#{act[:sport_type]})" : ""}"
40
+ say " #{act[:start_date]} | #{act[:distance]&.round(2)}m" if act[:distance]
41
+ say " #{act[:moving_time]}s" if act[:moving_time]
42
+ end
43
+
44
+ if options[:save]
45
+ db = connect_db
46
+ now = Time.now.utc
47
+ activities.each do |act|
48
+ existing = db[:activities].where(run_id: act[:run_id]).first
49
+ if existing
50
+ db[:activities].where(run_id: act[:run_id]).update(act.merge(updated_at: now))
51
+ else
52
+ db[:activities].insert(act.merge(created_at: now, updated_at: now))
53
+ end
54
+ end
55
+ say_status :done, "Saved #{activities.size} activities", :green
56
+ end
57
+ rescue => e
58
+ say_status :error, "Parse failed: #{e.message}", :red
59
+ end
60
+
18
61
  desc "init", "Generate config.yml"
19
62
  option :output, type: :string, desc: "Output path", aliases: "-o"
20
63
  def init
@@ -49,6 +92,10 @@ module GitFit
49
92
  result
50
93
  end
51
94
 
95
+ def connect_db
96
+ GitFit::DB::Connection.new(git_fit_config.db_path).db
97
+ end
98
+
52
99
  def deep_dup(obj)
53
100
  case obj
54
101
  when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
@@ -0,0 +1,15 @@
1
+ # Deprecated: use GitFit.activity_to_hash / GitFit.format_time instead.
2
+ # Kept for backward compatibility during migration.
3
+ module GitFit
4
+ module DB
5
+ SPORT_CATEGORIES = GitFit::SPORT_CATEGORIES
6
+
7
+ def self.activity_to_hash(a)
8
+ GitFit.activity_to_hash(a)
9
+ end
10
+
11
+ def self.format_time(seconds)
12
+ GitFit.format_time(seconds)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ require "thor"
2
+
3
+ module GitFit
4
+ module DB
5
+ class CLI < Thor
6
+ desc "migrate", "Run database migrations"
7
+ def migrate
8
+ config = GitFit::Config.new
9
+ GitFit::DB::Connection.new(config.db_path)
10
+ say_status :done, "Migrations up to date", :green
11
+ rescue => e
12
+ say_status :error, "Migration failed: #{e.message}", :red
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ require "fileutils"
2
+ require "sequel"
3
+
4
+ module GitFit
5
+ module DB
6
+ class Connection
7
+ def initialize(db_path)
8
+ FileUtils.mkdir_p(File.dirname(db_path)) if db_path.include?("/")
9
+ @db = Sequel.sqlite(db_path)
10
+ @db.run("PRAGMA journal_mode=WAL")
11
+ @db.run("PRAGMA busy_timeout=5000")
12
+ run_migrations
13
+ end
14
+
15
+ def db
16
+ @db
17
+ end
18
+
19
+ private
20
+
21
+ def run_migrations
22
+ Sequel.extension :migration
23
+ Sequel::Migrator.run(@db, File.expand_path("../../../db/migrations", __dir__))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ module GitFit
2
+ module Export
3
+ module Defaults
4
+ SVG = {
5
+ background: "#222222", text: "#FFFFFF", track: "#4DD2FF",
6
+ track2: "#4DD2FF", special: "#FFFF00", special2: "#FFFF00",
7
+ empty_cell: "#444444", dim_past: "#555555", font: "Arial",
8
+ }.freeze
9
+
10
+ DASHBOARD = {
11
+ background: "#1a1a2e", surface: "#16213e", hover: "#0f3460",
12
+ active_row: "#1a3a6a", border: "#2a2a4a", text: "#e0e0e0",
13
+ text_secondary: "#888", text_dim: "#666", text_insight: "#aaa",
14
+ accent: "#4DD2FF", font: "-apple-system, BlinkMacSystemFont, sans-serif",
15
+ }.freeze
16
+
17
+ CATEGORIES = {
18
+ "run" => "#4DD2FF", "walk" => "#4ECDC4",
19
+ "ride" => "#FF6B6B", "hike" => "#FFD93D",
20
+ }.freeze
21
+
22
+ MAP = {
23
+ tiles_dark: "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png",
24
+ tiles_light: "https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png",
25
+ echarts_theme: "dark",
26
+ }.freeze
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,112 @@
1
+ require "json"
2
+
3
+ module GitFit
4
+ module Export
5
+ class JSON
6
+ PLACEHOLDER_POLYLINE = "gqqrFurkeU??".freeze
7
+
8
+ def initialize(db:, output:, activity_filter: nil)
9
+ @db = db
10
+ @output = output
11
+ @activity_filter = activity_filter
12
+ end
13
+
14
+ def call
15
+ dataset = @db[:activities].order(Sequel.desc(:start_date))
16
+ dataset = dataset.where(sport_category: @activity_filter) if @activity_filter
17
+
18
+ activities = dataset.all
19
+
20
+ run_id_to_group = {}
21
+ @db[:dedup_group_members].each { |gm| run_id_to_group[gm[:run_id]] = gm[:group_id] }
22
+
23
+ group_members_cache = {}
24
+
25
+ formatted = activities.map do |a|
26
+ gid = run_id_to_group[a[:run_id]]
27
+ if gid
28
+ group_members_cache[gid] ||= @db[:dedup_group_members].where(group_id: gid).all
29
+ composite_activity(a, gid, group_members_cache[gid])
30
+ else
31
+ format_activity(a)
32
+ end
33
+ end
34
+
35
+ File.write(@output, ::JSON.pretty_generate(formatted))
36
+ formatted.size
37
+ end
38
+
39
+ private
40
+
41
+ def format_activity(a)
42
+ base = {
43
+ run_id: a[:run_id],
44
+ name: a[:name],
45
+ distance: a[:distance],
46
+ moving_time: a[:moving_time],
47
+ type: a[:sport_category],
48
+ sport_type: a[:sport_type],
49
+ start_date: a[:start_date],
50
+ start_date_local: a[:start_date_local],
51
+ location_country: a[:location_country],
52
+ summary_polyline: a[:source] == "keep" && a[:summary_polyline] == PLACEHOLDER_POLYLINE ?
53
+ nil : a[:summary_polyline],
54
+ average_heartrate: a[:average_heartrate] && a[:average_heartrate] != 0 ? a[:average_heartrate] : nil,
55
+ max_heartrate: a[:max_heartrate],
56
+ average_cadence: a[:average_cadence],
57
+ max_cadence: a[:max_cadence],
58
+ average_power: a[:average_power],
59
+ max_power: a[:max_power],
60
+ calories: a[:calories],
61
+ average_temperature: a[:average_temperature],
62
+ average_speed: a[:average_speed],
63
+ elevation_gain: a[:elevation_gain],
64
+ source: a[:source],
65
+ }
66
+ if a[:divisions]
67
+ base[:divisions] = ::JSON.parse(a[:divisions])
68
+ end
69
+ base
70
+ end
71
+
72
+ def composite_activity(activity, group_id, members)
73
+ run_ids = members.map { |m| m[:run_id] }
74
+ group_activities = @db[:activities].where(run_id: run_ids).all
75
+
76
+ composite = if defined?(GitFit::Dedup::FieldElector)
77
+ elector = GitFit::Dedup::FieldElector.new
78
+ result = {}
79
+ GitFit::Dedup::FieldElector::FIELD_ELECTION_RULES.each_key do |field|
80
+ values = group_activities.map { |a| a[field] }
81
+ sources = group_activities.map { |a| a[:source]&.to_sym }
82
+ result[field] = elector.elect(field, values, sources)
83
+ end
84
+ result
85
+ else
86
+ members.first&.slice(:run_id, :distance, :moving_time, :elapsed_time,
87
+ :average_heartrate, :max_heartrate, :elevation_gain,
88
+ :average_speed, :average_cadence, :max_cadence) || {}
89
+ end
90
+
91
+ composite[:run_id] = activity[:run_id]
92
+ composite[:source] = activity[:source]
93
+ composite[:sport_type] = activity[:sport_type]
94
+ composite[:start_date_local] = activity[:start_date_local]
95
+ composite[:location_country] = activity[:location_country]
96
+ composite[:sport_category] = activity[:sport_category]
97
+
98
+ if composite[:distance].to_f > 0 && composite[:moving_time].to_f > 0
99
+ composite[:average_speed] = composite[:distance] / composite[:moving_time]
100
+ end
101
+
102
+ composite[:type] = composite[:sport_category]
103
+ composite[:dedup_group] = group_id
104
+ composite[:member_sources] = members.map do |m|
105
+ { source: m[:source], run_id: m[:run_id], flag: m[:flag], confidence: m[:confidence] }
106
+ end
107
+
108
+ composite
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,7 @@
1
+ module GitFit
2
+ module Export
3
+ end
4
+ end
5
+
6
+ require_relative "export/defaults"
7
+ require_relative "export/json"
@@ -0,0 +1,27 @@
1
+ require "mini_racer"
2
+ require "json"
3
+
4
+ module GitFit
5
+ module FIT
6
+ class Decoder
7
+ JS_CODE = File.read(File.join(__dir__, "decoder.js")).freeze
8
+ CTX = MiniRacer::Context.new.tap { |c| c.eval(JS_CODE) }
9
+
10
+ def self.decode(path)
11
+ bytes = File.read(path, mode: "rb").bytes
12
+ json = CTX.eval("FitDecoder.decodeFit(#{bytes.inspect})")
13
+ records = JSON.parse(json)
14
+ records.each do |r|
15
+ r["positionLat"] *= 180 if r["positionLat"]
16
+ r["positionLong"] *= 180 if r["positionLong"]
17
+ end
18
+ records
19
+ end
20
+
21
+ def self.sport(path)
22
+ bytes = File.read(path, mode: "rb").bytes
23
+ CTX.eval("FitDecoder.fitSport(#{bytes.inspect})")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1 @@
1
+ require_relative "fit/decoder"
@@ -0,0 +1,101 @@
1
+ module GitFit
2
+ module Geo
3
+ # 3 coordinate systems: WGS84 (GPS native), GCJ-02 (China offset), BD-09 (Baidu).
4
+ module CoordTransform
5
+ A = 6378245.0
6
+ EE = 0.00669342162296594323
7
+
8
+ X_PI = Math::PI * 3000.0 / 180.0
9
+
10
+ module_function
11
+
12
+ def wgs84_to_gcj02(lat, lng)
13
+ return [lat, lng] if out_of_china(lng, lat)
14
+ dlat, dlng = delta(lat, lng)
15
+ [lat + dlat, lng + dlng]
16
+ end
17
+
18
+ def gcj02_to_wgs84(lat, lng)
19
+ return [lat, lng] if out_of_china(lng, lat)
20
+ dlat, dlng = delta(lat, lng)
21
+ [lat - dlat, lng - dlng]
22
+ end
23
+
24
+ def gcj02_to_wgs84_exact(lat, lng)
25
+ return [lat, lng] if out_of_china(lng, lat)
26
+ init_lat = lat
27
+ init_lng = lng
28
+ 5.times do
29
+ dlat, dlng = delta(init_lat, init_lng)
30
+ init_lat = lat - dlat
31
+ init_lng = lng - dlng
32
+ end
33
+ [init_lat, init_lng]
34
+ end
35
+
36
+ def gcj02_to_bd09(lat, lng)
37
+ return [lat, lng] if out_of_china(lng, lat)
38
+ x = lng
39
+ y = lat
40
+ z = Math.sqrt(x * x + y * y) + 0.00002 * Math.sin(y * X_PI)
41
+ theta = Math.atan2(y, x) + 0.000003 * Math.cos(x * X_PI)
42
+ bd_lng = z * Math.cos(theta) + 0.0065
43
+ bd_lat = z * Math.sin(theta) + 0.006
44
+ [bd_lat, bd_lng]
45
+ end
46
+
47
+ def bd09_to_gcj02(lat, lng)
48
+ return [lat, lng] if out_of_china(lng, lat)
49
+ x = lng - 0.0065
50
+ y = lat - 0.006
51
+ z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI)
52
+ theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI)
53
+ gcj_lng = z * Math.cos(theta)
54
+ gcj_lat = z * Math.sin(theta)
55
+ [gcj_lat, gcj_lng]
56
+ end
57
+
58
+ def bd09_to_wgs84(lat, lng)
59
+ gcj_lat, gcj_lng = bd09_to_gcj02(lat, lng)
60
+ gcj02_to_wgs84_exact(gcj_lat, gcj_lng)
61
+ end
62
+
63
+ def wgs84_to_bd09(lat, lng)
64
+ gcj_lat, gcj_lng = wgs84_to_gcj02(lat, lng)
65
+ gcj02_to_bd09(gcj_lat, gcj_lng)
66
+ end
67
+
68
+ def out_of_china(lng, lat)
69
+ lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
70
+ end
71
+
72
+ def delta(lat, lng)
73
+ dlat = transform_lat(lng - 105.0, lat - 35.0)
74
+ dlng = transform_lng(lng - 105.0, lat - 35.0)
75
+ radlat = lat / 180.0 * Math::PI
76
+ magic = Math.sin(radlat)
77
+ magic = 1 - EE * magic * magic
78
+ sqrt_magic = Math.sqrt(magic)
79
+ dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrt_magic) * Math::PI)
80
+ dlng = (dlng * 180.0) / (A / sqrt_magic * Math.cos(radlat) * Math::PI)
81
+ [dlat, dlng]
82
+ end
83
+
84
+ def transform_lat(x, y)
85
+ ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(x.abs)
86
+ ret += (20.0 * Math.sin(6.0 * x * Math::PI) + 20.0 * Math.sin(2.0 * x * Math::PI)) * 2.0 / 3.0
87
+ ret += (20.0 * Math.sin(y * Math::PI) + 40.0 * Math.sin(y / 3.0 * Math::PI)) * 2.0 / 3.0
88
+ ret += (160.0 * Math.sin(y / 12.0 * Math::PI) + 320.0 * Math.sin(y * Math::PI / 30.0)) * 2.0 / 3.0
89
+ ret
90
+ end
91
+
92
+ def transform_lng(x, y)
93
+ ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(x.abs)
94
+ ret += (20.0 * Math.sin(6.0 * x * Math::PI) + 20.0 * Math.sin(2.0 * x * Math::PI)) * 2.0 / 3.0
95
+ ret += (20.0 * Math.sin(x * Math::PI) + 40.0 * Math.sin(x / 3.0 * Math::PI)) * 2.0 / 3.0
96
+ ret += (150.0 * Math.sin(x / 12.0 * Math::PI) + 300.0 * Math.sin(x / 30.0 * Math::PI)) * 2.0 / 3.0
97
+ ret
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,78 @@
1
+ module GitFit
2
+ module Geo
3
+ module Polyline
4
+ module_function
5
+
6
+ def decode(str)
7
+ return nil unless str && str.length > 0
8
+
9
+ points = []
10
+ index = 0
11
+ lat = 0
12
+ lng = 0
13
+
14
+ while index < str.length
15
+ shift = 0
16
+ result = 0
17
+ begin
18
+ byte = str[index].ord - 63
19
+ index += 1
20
+ result |= (byte & 0x1f) << shift
21
+ shift += 5
22
+ end while byte >= 0x20
23
+
24
+ dlat = (result & 1) != 0 ? ~(result >> 1) : (result >> 1)
25
+ lat += dlat
26
+
27
+ shift = 0
28
+ result = 0
29
+ begin
30
+ byte = str[index].ord - 63
31
+ index += 1
32
+ result |= (byte & 0x1f) << shift
33
+ shift += 5
34
+ end while byte >= 0x20
35
+
36
+ dlng = (result & 1) != 0 ? ~(result >> 1) : (result >> 1)
37
+ lng += dlng
38
+
39
+ points << [lat * 1e-5, lng * 1e-5]
40
+ end
41
+
42
+ points
43
+ end
44
+
45
+ def encode(points)
46
+ result = ""
47
+ plat = 0
48
+ plng = 0
49
+
50
+ points.each do |lat, lng|
51
+ dlat = ((lat * 1e5).round - plat)
52
+ dlng = ((lng * 1e5).round - plng)
53
+
54
+ plat += dlat
55
+ plng += dlng
56
+
57
+ result += encode_number(dlat)
58
+ result += encode_number(dlng)
59
+ end
60
+
61
+ result
62
+ end
63
+
64
+ def encode_number(num)
65
+ num = num < 0 ? ~(num << 1) : (num << 1)
66
+ result = ""
67
+
68
+ while num >= 0x20
69
+ result += ((0x20 | (num & 0x1f)) + 63).chr
70
+ num >>= 5
71
+ end
72
+
73
+ result += (num + 63).chr
74
+ result
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ module GitFit
2
+ module Geo
3
+ end
4
+ end
5
+
6
+ require_relative "geo/polyline"
7
+ require_relative "geo/coord_transform"