seaports 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +28 -0
- data/README.md +132 -0
- data/data/un_locode_seaports.csv +17521 -0
- data/lib/seaports/builder.rb +114 -0
- data/lib/seaports/diff.rb +174 -0
- data/lib/seaports/version.rb +11 -0
- data/lib/seaports.rb +121 -0
- metadata +70 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../seaports"
|
|
7
|
+
|
|
8
|
+
module Seaports
|
|
9
|
+
# Rebuilds the shipped sea-port table from a published UN/LOCODE code list.
|
|
10
|
+
#
|
|
11
|
+
# The table is generated, not hand-maintained, and this is the only thing
|
|
12
|
+
# that should ever write it — so a refresh is `rake seaports:refresh` against
|
|
13
|
+
# a newer UNECE release rather than a diff someone edits by hand.
|
|
14
|
+
#
|
|
15
|
+
# UNECE's own distribution is a zip of headerless latin-1 CSVs, so the source
|
|
16
|
+
# here is the datasets/un-locode mirror, which republishes the same list with
|
|
17
|
+
# headers, UTF-8 and a machine-readable release number. It is a third party,
|
|
18
|
+
# which is why every refresh runs through Seaports::Diff before anything
|
|
19
|
+
# ships.
|
|
20
|
+
#
|
|
21
|
+
# Nothing breaks if a release is skipped: a port missing from our copy falls
|
|
22
|
+
# back to whatever the caller rendered before the table existed.
|
|
23
|
+
class Builder
|
|
24
|
+
SOURCE_URL = "https://raw.githubusercontent.com/datasets/un-locode/main/data/code-list.csv"
|
|
25
|
+
DATAPACKAGE_URL = "https://raw.githubusercontent.com/datasets/un-locode/main/datapackage.json"
|
|
26
|
+
|
|
27
|
+
HEADERS = %w[locode name lat lng].freeze
|
|
28
|
+
|
|
29
|
+
# The published list, straight off the network — or off disk, if what was
|
|
30
|
+
# handed over is a path. Both, because a rebuild from an already-downloaded
|
|
31
|
+
# release is how this is debugged, and it should not need a different
|
|
32
|
+
# entry point. Kept separate from parsing so every other path through this
|
|
33
|
+
# class can be tested with a string.
|
|
34
|
+
def self.fetch(source = SOURCE_URL)
|
|
35
|
+
return File.read(source) if File.exist?(source)
|
|
36
|
+
|
|
37
|
+
require "open-uri"
|
|
38
|
+
URI.parse(source).read
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Which UN/LOCODE edition the mirror is currently publishing, as UNECE
|
|
42
|
+
# names it: "2024.2.0" upstream becomes "2024-2" here. Nil rather than a
|
|
43
|
+
# guess if the mirror changes shape — a wrong release number is worse than
|
|
44
|
+
# an absent one, because it would be believed.
|
|
45
|
+
def self.published_release(source = DATAPACKAGE_URL)
|
|
46
|
+
version = JSON.parse(fetch(source))["version"].to_s
|
|
47
|
+
match = version.match(/\A(\d{4})\.(\d)/)
|
|
48
|
+
match && "#{match[1]}-#{match[2]}"
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(source)
|
|
54
|
+
@source = source
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# [locode, name, lat, lng] per port, deduplicated and sorted by locode.
|
|
58
|
+
# Sorted because the file is committed: a stable order means a refresh
|
|
59
|
+
# diff shows what changed rather than the whole table moving.
|
|
60
|
+
def rows
|
|
61
|
+
@rows ||= CSV.parse(@source, headers: true)
|
|
62
|
+
.select { |row| seaport?(row) }
|
|
63
|
+
.map { |row| port_row(row) }
|
|
64
|
+
.uniq { |locode, *| locode }
|
|
65
|
+
.sort_by(&:first)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def write(path = Seaports::TABLE_PATH)
|
|
69
|
+
CSV.open(path, "w") do |csv|
|
|
70
|
+
csv << HEADERS
|
|
71
|
+
rows.each { |row| csv << row }
|
|
72
|
+
end
|
|
73
|
+
path
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def located_count
|
|
77
|
+
rows.count { |row| !row[2].nil? }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# UNECE classifies every entry by function, and position 1 of that string
|
|
83
|
+
# is "port" — the only one a vessel can call at. Keeping the other 98k
|
|
84
|
+
# entries would mean a road terminal or a postal exchange could answer a
|
|
85
|
+
# lookup for a ship's port call, which is worse than not answering at all.
|
|
86
|
+
#
|
|
87
|
+
# `Change` marks an entry's fate in the current release; "X" is one being
|
|
88
|
+
# removed, and those are dropped.
|
|
89
|
+
def seaport?(row)
|
|
90
|
+
row["Function"].to_s.include?("1") &&
|
|
91
|
+
!row["Change"].to_s.include?("X") &&
|
|
92
|
+
!row["Location"].to_s.strip.empty? &&
|
|
93
|
+
!row["Name"].to_s.strip.empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def port_row(row)
|
|
97
|
+
lat, lng = decimal_degrees(row["Coordinates"])
|
|
98
|
+
["#{row['Country']}#{row['Location']}".upcase, row["Name"].strip, lat, lng]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# UNECE writes coordinates as "2421N 11812E" — degrees and whole minutes,
|
|
102
|
+
# zero-padded, no separator. Rounded to four places, which is metres: the
|
|
103
|
+
# source only resolves to a minute (about a mile), and writing more digits
|
|
104
|
+
# than that would dress a coarse figure up as a survey.
|
|
105
|
+
def decimal_degrees(value)
|
|
106
|
+
match = value.to_s.strip.match(/\A(\d{2})(\d{2})([NS])\s+(\d{3})(\d{2})([EW])\z/)
|
|
107
|
+
return [nil, nil] if match.nil?
|
|
108
|
+
|
|
109
|
+
lat = (match[1].to_i + (match[2].to_i / 60.0)) * (match[3] == "S" ? -1 : 1)
|
|
110
|
+
lng = (match[4].to_i + (match[5].to_i / 60.0)) * (match[6] == "W" ? -1 : 1)
|
|
111
|
+
[lat.round(4), lng.round(4)]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Seaports
|
|
6
|
+
# What a refresh actually changed, and whether it is safe to ship.
|
|
7
|
+
#
|
|
8
|
+
# The failure mode worth designing for is not "we missed a port". It is a bad
|
|
9
|
+
# upstream release — a truncated file, a changed column, a mirror that
|
|
10
|
+
# rebuilt itself wrong — landing in a gem that thousands of lookups trust. A
|
|
11
|
+
# human reading a pull request cannot eyeball 17,000 rows, so the gates below
|
|
12
|
+
# are the reading, and the summary is what is left to judge.
|
|
13
|
+
class Diff
|
|
14
|
+
# Ports large enough that their disappearance means the source broke, not
|
|
15
|
+
# that the world changed. Spread across continents so a regional truncation
|
|
16
|
+
# cannot slip through, and every one of them is a port a tracking feed
|
|
17
|
+
# names on an ordinary day.
|
|
18
|
+
ANCHORS = %w[
|
|
19
|
+
AEJEA AUBNE BEANR CNSGH CNXMG COCTG DEHAM ESALG ESVLC GBFXT
|
|
20
|
+
HKHKG KRPUS NLRTM NZTRG SGSIN USLAX
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Codes that must stay out. PAPCN is the Panama Canal, which UNECE files as
|
|
24
|
+
# an unverified road terminal — see the note in Seaports. If a future
|
|
25
|
+
# release reclassifies it as a port, that should be a decision someone
|
|
26
|
+
# makes, not a row that appears quietly.
|
|
27
|
+
EXCLUDED = %w[PAPCN].freeze
|
|
28
|
+
|
|
29
|
+
MINIMUM_COUNT = 15_000
|
|
30
|
+
|
|
31
|
+
# A real UNECE release adds and retires a handful of entries. Losing two
|
|
32
|
+
# percent of the table in one go is a broken source, not a busy half-year.
|
|
33
|
+
MAXIMUM_SHRINKAGE = 0.02
|
|
34
|
+
|
|
35
|
+
def self.between(before_path, after_path)
|
|
36
|
+
new(read(before_path), read(after_path))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.read(path)
|
|
40
|
+
CSV.foreach(path, headers: true).each_with_object({}) do |row, ports|
|
|
41
|
+
locode = row["locode"].to_s.strip.upcase
|
|
42
|
+
next if locode.empty?
|
|
43
|
+
|
|
44
|
+
ports[locode] = { name: row["name"].to_s.strip, point: point(row) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.point(row)
|
|
49
|
+
lat = row["lat"].to_s.strip
|
|
50
|
+
lng = row["lng"].to_s.strip
|
|
51
|
+
return nil if lat.empty? || lng.empty?
|
|
52
|
+
|
|
53
|
+
[lat.to_f, lng.to_f]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize(before, after)
|
|
57
|
+
@before = before
|
|
58
|
+
@after = after
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def changed?
|
|
62
|
+
!(added.empty? && removed.empty? && renamed.empty? && repositioned.empty?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def added
|
|
66
|
+
@added ||= (@after.keys - @before.keys).sort
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def removed
|
|
70
|
+
@removed ||= (@before.keys - @after.keys).sort
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# [locode, was, now] for a port that kept its code and changed its name.
|
|
74
|
+
def renamed
|
|
75
|
+
@renamed ||= common.filter_map do |locode|
|
|
76
|
+
was = @before[locode][:name]
|
|
77
|
+
now = @after[locode][:name]
|
|
78
|
+
[locode, was, now] if was != now
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Every kind of position change in one list, tagged, because they read
|
|
83
|
+
# differently downstream: a port that gains coordinates puts a new marker
|
|
84
|
+
# on a map, and one that loses them takes a marker away. Both are worth
|
|
85
|
+
# seeing before a release, and neither is a failure.
|
|
86
|
+
def repositioned
|
|
87
|
+
@repositioned ||= common.filter_map do |locode|
|
|
88
|
+
was = @before[locode][:point]
|
|
89
|
+
now = @after[locode][:point]
|
|
90
|
+
next if was == now
|
|
91
|
+
|
|
92
|
+
[locode, @after[locode][:name], (was.nil? ? :located : (now.nil? ? :unlocated : :moved))]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Empty means the refresh may ship. Anything here is a reason it may not,
|
|
97
|
+
# written the way it should read in an issue.
|
|
98
|
+
def failures
|
|
99
|
+
[count_failure, shrinkage_failure, *anchor_failures, *excluded_failures].compact
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ok?
|
|
103
|
+
failures.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def summary
|
|
107
|
+
[
|
|
108
|
+
headline,
|
|
109
|
+
failures.empty? ? nil : "**Blocked**\n\n#{failures.map { |line| "- #{line}" }.join("\n")}",
|
|
110
|
+
section("Removed", removed.map { |locode| "`#{locode}` #{@before[locode][:name]}" }, limit: nil),
|
|
111
|
+
section("Added", added.map { |locode| "`#{locode}` #{@after[locode][:name]}" }),
|
|
112
|
+
section("Renamed", renamed.map { |locode, was, now| "`#{locode}` #{was} → #{now}" }),
|
|
113
|
+
section("Coordinates", repositioned.map { |locode, name, kind| "`#{locode}` #{name} — #{POSITION_WORDS.fetch(kind)}" })
|
|
114
|
+
].compact.join("\n\n")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
POSITION_WORDS = {
|
|
118
|
+
located: "position published for the first time",
|
|
119
|
+
unlocated: "position withdrawn",
|
|
120
|
+
moved: "position moved"
|
|
121
|
+
}.freeze
|
|
122
|
+
|
|
123
|
+
# The one-line version, for a changelog entry that should not carry four
|
|
124
|
+
# hundred bullet points.
|
|
125
|
+
def headline
|
|
126
|
+
"#{@after.size} sea ports (#{@before.size} before): " \
|
|
127
|
+
"#{added.size} added, #{removed.size} removed, " \
|
|
128
|
+
"#{renamed.size} renamed, #{repositioned.size} repositioned."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def common
|
|
134
|
+
@common ||= (@before.keys & @after.keys).sort
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def count_failure
|
|
138
|
+
return nil if @after.size >= MINIMUM_COUNT
|
|
139
|
+
|
|
140
|
+
"Only #{@after.size} ports in the rebuilt table, below the floor of #{MINIMUM_COUNT}."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def shrinkage_failure
|
|
144
|
+
return nil if @before.empty?
|
|
145
|
+
|
|
146
|
+
shrinkage = (@before.size - @after.size).to_f / @before.size
|
|
147
|
+
return nil if shrinkage <= MAXIMUM_SHRINKAGE
|
|
148
|
+
|
|
149
|
+
"The table shrank by #{(shrinkage * 100).round(1)}%, past the #{(MAXIMUM_SHRINKAGE * 100).round}% limit."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def anchor_failures
|
|
153
|
+
ANCHORS.reject { |locode| @after.key?(locode) }
|
|
154
|
+
.map { |locode| "`#{locode}` is gone, and a port that large does not disappear." }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def excluded_failures
|
|
158
|
+
EXCLUDED.select { |locode| @after.key?(locode) }
|
|
159
|
+
.map { |locode| "`#{locode}` is now classified as a sea port upstream, which is a decision to make deliberately." }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Removals are listed in full — they are the changes that break a caller.
|
|
163
|
+
# Everything else is capped, because a release that adds four hundred ports
|
|
164
|
+
# should not bury the four that were taken away.
|
|
165
|
+
def section(title, lines, limit: 40)
|
|
166
|
+
return nil if lines.empty?
|
|
167
|
+
|
|
168
|
+
shown = limit.nil? ? lines : lines.first(limit)
|
|
169
|
+
body = shown.map { |line| "- #{line}" }.join("\n")
|
|
170
|
+
body += "\n- …and #{lines.size - shown.size} more" if lines.size > shown.size
|
|
171
|
+
"**#{title}** (#{lines.size})\n\n#{body}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seaports
|
|
4
|
+
VERSION = "1.0.0"
|
|
5
|
+
|
|
6
|
+
# The UN/LOCODE edition the shipped table was built from. UNECE republishes
|
|
7
|
+
# twice a year and names each release by year and half — "2024-2" is the
|
|
8
|
+
# second release of 2024. A gem version says what the code does; this says
|
|
9
|
+
# how old the data is, which is the question a caller actually has.
|
|
10
|
+
DATA_RELEASE = "2024-2"
|
|
11
|
+
end
|
data/lib/seaports.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
require_relative "seaports/version"
|
|
6
|
+
|
|
7
|
+
# UN/LOCODE sea ports: a locode in, a port name and position out.
|
|
8
|
+
#
|
|
9
|
+
# Tracking feeds speak in codes. A provider names some port calls and sends
|
|
10
|
+
# only a code for others, so a stop that mattered arrives as a bare "XMG" — a
|
|
11
|
+
# real, load-bearing fact about a voyage written in a vocabulary nobody reads.
|
|
12
|
+
# UNECE publishes the table that turns it back into "Xiamen Pt", and publishes
|
|
13
|
+
# coordinates alongside, so one dataset answers both "where is this?" and
|
|
14
|
+
# "where do I draw it?".
|
|
15
|
+
#
|
|
16
|
+
# The table ships inside the gem rather than being fetched: it is reference
|
|
17
|
+
# data that changes twice a year, and a port name should not depend on a
|
|
18
|
+
# network. `rake seaports:refresh` regenerates it; nothing else should write it.
|
|
19
|
+
#
|
|
20
|
+
# Seaports.find("CNXMG") # => #<data Seaports::Port locode="CNXMG", ...>
|
|
21
|
+
# Seaports.name("AUBNE") # => "Brisbane"
|
|
22
|
+
# Seaports.coordinates("AUBNE") # => { "lat" => -27.4667, "lng" => 153.0167 }
|
|
23
|
+
#
|
|
24
|
+
# Coverage is sea ports only, which is what a vessel's port call is, and
|
|
25
|
+
# UNECE's own classification decides what counts. The notable absence is
|
|
26
|
+
# PAPCN, "Panama Canal", which some feeds do send: UNECE files it as a road
|
|
27
|
+
# terminal with status RL — "recognised location", its weakest, meaning no
|
|
28
|
+
# national authority has approved it and its functions were never verified.
|
|
29
|
+
# That is the default bucket for an unchecked entry rather than a ruling, but
|
|
30
|
+
# the substance holds anyway, because a canal is a waterway and not a port. The
|
|
31
|
+
# ports at either end are coded properly (PABLB Balboa, PACTB Cristóbal), so a
|
|
32
|
+
# canal transit is better rendered from the country than from a port lookup.
|
|
33
|
+
#
|
|
34
|
+
# Keeping the filter narrow also guards anyone rebuilding a five-character
|
|
35
|
+
# locode from a three-character carrier code, which is a heuristic: a code that
|
|
36
|
+
# is not a locode tail could pair with a country to form a valid-looking locode
|
|
37
|
+
# for some inland village, and a table of ports is far less likely to name it.
|
|
38
|
+
module Seaports
|
|
39
|
+
TABLE_PATH = File.expand_path("../data/un_locode_seaports.csv", __dir__)
|
|
40
|
+
|
|
41
|
+
Port = Data.define(:locode, :name, :coordinates)
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# Nil for anything the table does not hold, which callers must handle:
|
|
45
|
+
# ~17k ports is most of the world's, not all of it, and an unknown code is
|
|
46
|
+
# the ordinary case rather than an error.
|
|
47
|
+
def find(locode)
|
|
48
|
+
key = normalize(locode)
|
|
49
|
+
return nil if key.nil?
|
|
50
|
+
|
|
51
|
+
table[key]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def name(locode)
|
|
55
|
+
find(locode)&.name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# { "lat" => Float, "lng" => Float }, or nil. String keys because the usual
|
|
59
|
+
# destination is JSON on its way to a map. Roughly a third of the table has
|
|
60
|
+
# no coordinates published, so a named port with no position is normal.
|
|
61
|
+
def coordinates(locode)
|
|
62
|
+
find(locode)&.coordinates
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Every port in the table, ordered by locode. Materialised on each call
|
|
66
|
+
# rather than handed out from the cache, so a caller cannot mutate the
|
|
67
|
+
# table everyone else reads.
|
|
68
|
+
def all
|
|
69
|
+
table.values.dup
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# How many ports the table holds — the guard that a truncated or
|
|
73
|
+
# half-written CSV is caught by a test rather than by a production page.
|
|
74
|
+
def count
|
|
75
|
+
table.size
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Which UN/LOCODE edition the shipped table came from, for a caller that
|
|
79
|
+
# wants to assert its data is not years old.
|
|
80
|
+
def data_release
|
|
81
|
+
DATA_RELEASE
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# A locode is five characters: a two-letter country and a three-character
|
|
87
|
+
# place. Anything else is not one, and guessing at it would mean answering
|
|
88
|
+
# a question that was not asked.
|
|
89
|
+
def normalize(locode)
|
|
90
|
+
key = locode.to_s.strip.upcase
|
|
91
|
+
key.match?(/\A[A-Z]{2}[A-Z0-9]{3}\z/) ? key : nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Read once, on the first lookup rather than at require time: most
|
|
95
|
+
# processes never look a port up, and the ones that do can afford the parse.
|
|
96
|
+
def table
|
|
97
|
+
@table || LOAD_LOCK.synchronize { @table ||= load_table }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
LOAD_LOCK = Mutex.new
|
|
101
|
+
|
|
102
|
+
def load_table
|
|
103
|
+
CSV.foreach(TABLE_PATH, headers: true).each_with_object({}) do |row, ports|
|
|
104
|
+
locode = normalize(row["locode"])
|
|
105
|
+
next if locode.nil? || blank?(row["name"])
|
|
106
|
+
|
|
107
|
+
ports[locode] = Port.new(locode: locode, name: row["name"], coordinates: point(row))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def point(row)
|
|
112
|
+
return nil if blank?(row["lat"]) || blank?(row["lng"])
|
|
113
|
+
|
|
114
|
+
{ "lat" => row["lat"].to_f, "lng" => row["lng"].to_f }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def blank?(value)
|
|
118
|
+
value.to_s.strip.empty?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: seaports
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Édouard Brière
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: csv
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.3'
|
|
26
|
+
description: |
|
|
27
|
+
Every sea port UNECE publishes a UN/LOCODE for, as a lookup table with no
|
|
28
|
+
dependencies and no network calls. Turns the bare codes in an AIS or
|
|
29
|
+
carrier tracking feed back into port names and coordinates. The table is
|
|
30
|
+
regenerated from each UN/LOCODE release and shipped inside the gem.
|
|
31
|
+
email:
|
|
32
|
+
- edouard.briere@gmail.com
|
|
33
|
+
executables: []
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- LICENSE.txt
|
|
39
|
+
- README.md
|
|
40
|
+
- data/un_locode_seaports.csv
|
|
41
|
+
- lib/seaports.rb
|
|
42
|
+
- lib/seaports/builder.rb
|
|
43
|
+
- lib/seaports/diff.rb
|
|
44
|
+
- lib/seaports/version.rb
|
|
45
|
+
homepage: https://github.com/Trackberry/seaports
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
source_code_uri: https://github.com/Trackberry/seaports
|
|
50
|
+
changelog_uri: https://github.com/Trackberry/seaports/blob/main/CHANGELOG.md
|
|
51
|
+
bug_tracker_uri: https://github.com/Trackberry/seaports/issues
|
|
52
|
+
rubygems_mfa_required: 'true'
|
|
53
|
+
rdoc_options: []
|
|
54
|
+
require_paths:
|
|
55
|
+
- lib
|
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 3.2.0
|
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0'
|
|
66
|
+
requirements: []
|
|
67
|
+
rubygems_version: 3.6.9
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: 'UN/LOCODE sea ports: a locode in, a port name and position out.'
|
|
70
|
+
test_files: []
|