seaports 1.0.0 → 1.2.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.
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Seaports
6
+ # Which subdivision a point falls in, from Natural Earth's admin-1 map.
7
+ #
8
+ # UN/LOCODE leaves the subdivision blank for about a quarter of its ports,
9
+ # Amsterdam among them, but publishes coordinates for half of those. Natural
10
+ # Earth's states-and-provinces layer (public domain) tags each polygon with
11
+ # its ISO 3166-2 code, so a port with a position can be placed in one.
12
+ #
13
+ # Build-time only, like Seaports::Subdivisions: the 40 MB map is read when
14
+ # the table is built and the codes it finds are written into the table.
15
+ class Boundaries
16
+ # Pinned to a commit rather than master, so the map only changes when
17
+ # someone moves this line and reads what moved with it. ca96624 (June
18
+ # 2022) is master's head at the time of pinning.
19
+ SOURCE_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_1_states_provinces.geojson"
20
+
21
+ # UN/LOCODE gives positions to the minute, about two kilometres, and a
22
+ # port's position is usually its waterfront, so a fair share of ports land
23
+ # just offshore of the coastline Natural Earth drew. Within this distance
24
+ # of a subdivision of the port's own country, the port is placed in the
25
+ # nearest one; further out, it gets none rather than a guess.
26
+ NEAREST_WITHIN_KM = 10
27
+
28
+ KM_PER_DEGREE_LAT = 110.57
29
+ KM_PER_DEGREE_LNG = 111.32
30
+
31
+ # +codes+ is the area's own ISO 3166-2 code, then the codes of what it
32
+ # sits in, as far as Natural Earth records them. The map is a few years
33
+ # behind ISO in places (Northern Ireland's districts were merged in 2015
34
+ # and it still draws the old ones), so when the area's own code has been
35
+ # retired, its parent still places the port: Larne is gone, but it was in
36
+ # GB-NIR.
37
+ Area = Data.define(:codes, :polygons, :bbox) do
38
+ def country
39
+ codes.first[0, 2]
40
+ end
41
+ end
42
+
43
+ CODE = /\A[A-Z]{2}-[A-Z0-9]{1,3}\z/
44
+
45
+ def self.parse(geojson)
46
+ areas = JSON.parse(geojson).fetch("features").filter_map do |feature|
47
+ codes = codes_of(feature.fetch("properties"))
48
+ next if codes.empty?
49
+
50
+ polygons = polygons_of(feature["geometry"])
51
+ next if polygons.empty?
52
+
53
+ Area.new(codes: codes, polygons: polygons, bbox: bbox(polygons))
54
+ end
55
+ new(areas)
56
+ end
57
+
58
+ # The area's code, then its region's ("ES.VC" is written with a dot), then,
59
+ # for the UK, its nation (gu_a3 is "ENG", "NIR", "SCT" or "WLS").
60
+ #
61
+ # Areas whose own code is not shaped like ISO 3166-2 (Natural Earth's
62
+ # placeholders: "BA-X01" for Republika Srpska's municipalities, Kosovo's
63
+ # "XK-" codes, "-99") are skipped entirely, so a port inside one but
64
+ # within 10 km of a coded neighbour is placed in the neighbour. No port in
65
+ # the shipped table is today.
66
+ def self.codes_of(properties)
67
+ code = properties["iso_3166_2"].to_s
68
+ return [] unless code.match?(CODE)
69
+
70
+ country = code[0, 2]
71
+ region = properties["region_cod"].to_s.tr(".", "-")
72
+ nation = country == "GB" ? "GB-#{properties['gu_a3']}" : ""
73
+ [code, region, nation].select { |candidate| candidate.match?(CODE) && candidate.start_with?("#{country}-") }.uniq
74
+ end
75
+
76
+ # [[outer ring, hole, …], …] for a Polygon or MultiPolygon, each ring an
77
+ # array of [lng, lat].
78
+ def self.polygons_of(geometry)
79
+ case geometry&.fetch("type")
80
+ when "Polygon" then [geometry.fetch("coordinates")]
81
+ when "MultiPolygon" then geometry.fetch("coordinates")
82
+ else []
83
+ end
84
+ end
85
+
86
+ def self.bbox(polygons)
87
+ points = polygons.flat_map(&:first)
88
+ lngs = points.map(&:first)
89
+ lats = points.map(&:last)
90
+ [lngs.min, lats.min, lngs.max, lats.max]
91
+ end
92
+
93
+ def initialize(areas)
94
+ @by_country = areas.group_by(&:country)
95
+ end
96
+
97
+ # The codes (see Area) of the +country+ subdivision containing the point,
98
+ # or of the nearest one within NEAREST_WITHIN_KM; empty when there is
99
+ # none. Only the port's own country is searched, so a port on a border
100
+ # cannot be handed to its neighbour.
101
+ def codes_at(country, lat, lng)
102
+ areas = @by_country.fetch(country.to_s.upcase, [])
103
+ return [] if areas.empty? || lat.nil? || lng.nil?
104
+
105
+ containing = areas.find { |area| within_bbox?(area, lat, lng, 0) && contains?(area, lat, lng) }
106
+ (containing || nearest(areas, lat, lng))&.codes || []
107
+ end
108
+
109
+ private
110
+
111
+ # +lat_margin+ and +lng_margin+ in degrees. They differ because a degree
112
+ # of longitude shrinks towards the poles: 10 km is 0.09° of latitude
113
+ # anywhere, but 0.2° of longitude in northern Norway.
114
+ def within_bbox?(area, lat, lng, lat_margin, lng_margin = lat_margin)
115
+ min_lng, min_lat, max_lng, max_lat = area.bbox
116
+ lng.between?(min_lng - lng_margin, max_lng + lng_margin) && lat.between?(min_lat - lat_margin, max_lat + lat_margin)
117
+ end
118
+
119
+ def contains?(area, lat, lng)
120
+ area.polygons.any? do |outer, *holes|
121
+ inside?(outer, lat, lng) && holes.none? { |hole| inside?(hole, lat, lng) }
122
+ end
123
+ end
124
+
125
+ # Ray casting: a point is inside a ring if a ray from it crosses the ring
126
+ # an odd number of times.
127
+ def inside?(ring, lat, lng)
128
+ inside = false
129
+ ring.each_cons(2) do |(x1, y1), (x2, y2)|
130
+ next unless (y1 > lat) != (y2 > lat)
131
+
132
+ crossing = x1 + ((lat - y1) * (x2 - x1) / (y2 - y1))
133
+ inside = !inside if lng < crossing
134
+ end
135
+ inside
136
+ end
137
+
138
+ # Longitudes are compared as given, so a port a few kilometres across the
139
+ # antimeridian from its coast (Fiji, Chukotka) is left unplaced. It is
140
+ # never placed wrongly.
141
+ def nearest(areas, lat, lng)
142
+ lat_margin = NEAREST_WITHIN_KM / KM_PER_DEGREE_LAT
143
+ lng_margin = NEAREST_WITHIN_KM / (KM_PER_DEGREE_LNG * [Math.cos(lat * Math::PI / 180), 0.01].max)
144
+ best = areas.select { |area| within_bbox?(area, lat, lng, lat_margin, lng_margin) }
145
+ .map { |area| [area, distance_km(area, lat, lng)] }
146
+ .min_by(&:last)
147
+ best && best.last <= NEAREST_WITHIN_KM ? best.first : nil
148
+ end
149
+
150
+ # Shortest distance from the point to any edge of the area, on a flat
151
+ # projection centred on the point. Over ten kilometres the earth's
152
+ # curvature is far below the source's own two-kilometre precision.
153
+ #
154
+ # Hole edges count as edges, so a port inside a hole (a lake, or an
155
+ # enclave the map does not code) but within 10 km of its shore is placed
156
+ # in the area around it. That is the right answer for a lake port.
157
+ def distance_km(area, lat, lng)
158
+ scale = Math.cos(lat * Math::PI / 180) * KM_PER_DEGREE_LNG
159
+ area.polygons.flatten(1).flat_map { |ring| ring.each_cons(2).to_a }.map do |(x1, y1), (x2, y2)|
160
+ segment_distance(
161
+ (x1 - lng) * scale, (y1 - lat) * KM_PER_DEGREE_LAT,
162
+ (x2 - lng) * scale, (y2 - lat) * KM_PER_DEGREE_LAT
163
+ )
164
+ end.min || Float::INFINITY
165
+ end
166
+
167
+ # Distance from the origin to the segment (ax, ay)–(bx, by).
168
+ def segment_distance(ax, ay, bx, by)
169
+ dx = bx - ax
170
+ dy = by - ay
171
+ length = (dx * dx) + (dy * dy)
172
+ t = length.zero? ? 0 : (-((ax * dx) + (ay * dy)) / length).clamp(0, 1)
173
+ Math.hypot(ax + (t * dx), ay + (t * dy))
174
+ end
175
+ end
176
+ end
@@ -24,7 +24,13 @@ module Seaports
24
24
  SOURCE_URL = "https://raw.githubusercontent.com/datasets/un-locode/main/data/code-list.csv"
25
25
  DATAPACKAGE_URL = "https://raw.githubusercontent.com/datasets/un-locode/main/datapackage.json"
26
26
 
27
- HEADERS = %w[locode name lat lng].freeze
27
+ HEADERS = %w[locode name lat lng subdivision principal_subdivision subdivision_path subdivision_source].freeze
28
+
29
+ # Reviewed subdivisions for ports neither UN/LOCODE nor the map places:
30
+ # Valencia, Algeciras and Barcelona have no subdivision and no position.
31
+ # Kept outside data/*.csv so it is not shipped in the gem; only its result
32
+ # is.
33
+ OVERRIDES_PATH = File.expand_path("../../data/sources/subdivision_overrides.csv", __dir__)
28
34
 
29
35
  # The published list, straight off the network — or off disk, if what was
30
36
  # handed over is a path. Both, because a rebuild from an already-downloaded
@@ -50,11 +56,48 @@ module Seaports
50
56
  nil
51
57
  end
52
58
 
53
- def initialize(source)
59
+ # Reads OVERRIDES_PATH (or +path+) into { locode => ISO 3166-2 code }.
60
+ #
61
+ # The file is typed by people, so a blank cell, a malformed locode or a
62
+ # port listed twice stops the build with the line to fix, rather than
63
+ # crashing on a nil or letting the second row quietly win.
64
+ def self.overrides(path = OVERRIDES_PATH)
65
+ return {} unless File.exist?(path)
66
+
67
+ CSV.foreach(path, headers: true).with_index(2).each_with_object({}) do |(row, line), overrides|
68
+ locode = row["locode"].to_s.strip.upcase
69
+ code = row["subdivision"].to_s.strip.upcase
70
+ raise ArgumentError, "#{path}:#{line}: #{locode.inspect} is not a locode" unless locode.match?(/\A[A-Z]{2}[A-Z0-9]{3}\z/)
71
+ raise ArgumentError, "#{path}:#{line}: #{locode} has no subdivision" if code.empty?
72
+ raise ArgumentError, "#{path}:#{line}: #{locode} is listed twice" if overrides.key?(locode)
73
+
74
+ overrides[locode] = code
75
+ end
76
+ end
77
+
78
+ # Where a port's subdivision comes from, best first:
79
+ #
80
+ # - +overrides+, { locode => code }, reviewed by a person;
81
+ # - UN/LOCODE's own subdivision column;
82
+ # - +boundaries+ (Seaports::Boundaries), the subdivision the port's
83
+ # coordinates fall in.
84
+ #
85
+ # Every code is checked against +subdivisions+, the ISO 3166-2 list (see
86
+ # Seaports::Subdivisions), which also gives the principal subdivision
87
+ # above it. Without that list the subdivision columns stay empty, which
88
+ # keeps a test about something else from needing it.
89
+ def initialize(source, subdivisions: nil, boundaries: nil, overrides: {})
54
90
  @source = source
91
+ @subdivisions = subdivisions
92
+ @boundaries = boundaries
93
+ @overrides = overrides
94
+ @superseded = []
55
95
  end
56
96
 
57
- # [locode, name, lat, lng] per port, deduplicated and sorted by locode.
97
+ # [locode, name, lat, lng, subdivision, principal_subdivision,
98
+ # subdivision_path, subdivision_source] per port, deduplicated and sorted
99
+ # by locode. The path is the subdivision and everything above it, space
100
+ # separated, most specific first (see Seaports::Subdivisions#path).
58
101
  # Sorted because the file is committed: a stable order means a refresh
59
102
  # diff shows what changed rather than the whole table moving.
60
103
  def rows
@@ -77,6 +120,27 @@ module Seaports
77
120
  rows.count { |row| !row[2].nil? }
78
121
  end
79
122
 
123
+ def subdivided_count
124
+ rows.count { |row| !row[5].nil? }
125
+ end
126
+
127
+ # Overrides worth a person's attention, written the way they should read
128
+ # in a refresh pull request. An override wins over everything, so one that
129
+ # has outlived its reason keeps masking better data until someone looks:
130
+ #
131
+ # - its port is no longer in UN/LOCODE's sea-port list;
132
+ # - UN/LOCODE now gives the port a subdivision of its own.
133
+ def override_report
134
+ rows # the report is a by-product of building them
135
+ stale = (@overrides.keys - rows.map(&:first)).sort
136
+ .map { |locode| "`#{locode}` is overridden but no longer a UN/LOCODE sea port: remove its row." }
137
+ superseded = @superseded.uniq.sort.map do |locode, own, override|
138
+ same = own == override ? "the same code" : "#{own}, not #{override}"
139
+ "`#{locode}` now has a UN/LOCODE subdivision (#{same}): its override can go."
140
+ end
141
+ stale + superseded
142
+ end
143
+
80
144
  private
81
145
 
82
146
  # UNECE classifies every entry by function, and position 1 of that string
@@ -94,8 +158,35 @@ module Seaports
94
158
  end
95
159
 
96
160
  def port_row(row)
161
+ locode = "#{row['Country']}#{row['Location']}".upcase
97
162
  lat, lng = decimal_degrees(row["Coordinates"])
98
- ["#{row['Country']}#{row['Location']}".upcase, row["Name"].strip, lat, lng]
163
+ subdivision, source = subdivision_for(locode, row, lat, lng)
164
+ path = @subdivisions&.path(subdivision) || []
165
+ # A code whose chain breaks has no trustworthy top, so it is not kept.
166
+ subdivision = source = nil if path.empty?
167
+ [locode, row["Name"].strip, lat, lng, subdivision, path.last, path.empty? ? nil : path.join(" "), source]
168
+ end
169
+
170
+ # [code, source], or [nil, nil] when no source has a valid one.
171
+ def subdivision_for(locode, row, lat, lng)
172
+ return [nil, nil] if @subdivisions.nil?
173
+
174
+ country = row["Country"].to_s.strip.upcase
175
+ own = @subdivisions.code(country, row["Subdivision"])
176
+
177
+ if (override = @overrides[locode])
178
+ code = @subdivisions.code(country, override.delete_prefix("#{country}-"))
179
+ raise ArgumentError, "#{locode}: override #{override} is not an ISO 3166-2 code of #{country}" if code.nil?
180
+
181
+ @superseded << [locode, own, code] if own
182
+ return [code, "override"]
183
+ end
184
+
185
+ return [own, "unlocode"] if own
186
+
187
+ mapped = (@boundaries&.codes_at(country, lat, lng) || []).lazy
188
+ .filter_map { |candidate| @subdivisions.code(country, candidate.delete_prefix("#{country}-")) }.first
189
+ mapped ? [mapped, "boundaries"] : [nil, nil]
99
190
  end
100
191
 
101
192
  # UNECE writes coordinates as "2421N 11812E" — degrees and whole minutes,
data/lib/seaports/diff.rb CHANGED
@@ -41,10 +41,16 @@ module Seaports
41
41
  locode = row["locode"].to_s.strip.upcase
42
42
  next if locode.empty?
43
43
 
44
- ports[locode] = { name: row["name"].to_s.strip, point: point(row) }
44
+ ports[locode] = { name: row["name"].to_s.strip, point: point(row), path: path(row) }
45
45
  end
46
46
  end
47
47
 
48
+ # The port's subdivision chain as written, "GB-SFK GB-ENG". A table from
49
+ # before the path column existed has only the principal, which stands in.
50
+ def self.path(row)
51
+ (row["subdivision_path"] || row["principal_subdivision"]).to_s.split.join(" ")
52
+ end
53
+
48
54
  def self.point(row)
49
55
  lat = row["lat"].to_s.strip
50
56
  lng = row["lng"].to_s.strip
@@ -59,7 +65,7 @@ module Seaports
59
65
  end
60
66
 
61
67
  def changed?
62
- !(added.empty? && removed.empty? && renamed.empty? && repositioned.empty?)
68
+ !(added.empty? && removed.empty? && renamed.empty? && repositioned.empty? && resubdivided.empty?)
63
69
  end
64
70
 
65
71
  def added
@@ -93,10 +99,33 @@ module Seaports
93
99
  end
94
100
  end
95
101
 
102
+ # [locode, name, was, now] for a port whose subdivision path changed at
103
+ # any level, each path a space-separated string ("" for none). A county
104
+ # that moves inside the same nation changes Seaports.subdivision without
105
+ # touching the principal one, and it still has to ship. Each change can
106
+ # move a port onto another holiday calendar, so all are listed, though
107
+ # none is a failure on its own.
108
+ def resubdivided
109
+ @resubdivided ||= common.filter_map do |locode|
110
+ was = @before[locode][:path].to_s
111
+ now = @after[locode][:path].to_s
112
+ [locode, @after[locode][:name], was, now] if was != now
113
+ end
114
+ end
115
+
116
+ # The subset of resubdivided whose principal subdivision went from one
117
+ # code to another: placed before, placed now, somewhere else. Gaining or
118
+ # losing a code is not in it.
119
+ def reassigned
120
+ @reassigned ||= resubdivided.select do |_, _, was, now|
121
+ !was.empty? && !now.empty? && was.split.last != now.split.last
122
+ end
123
+ end
124
+
96
125
  # Empty means the refresh may ship. Anything here is a reason it may not,
97
126
  # written the way it should read in an issue.
98
127
  def failures
99
- [count_failure, shrinkage_failure, *anchor_failures, *excluded_failures].compact
128
+ [count_failure, shrinkage_failure, region_failure, reassignment_failure, *anchor_failures, *excluded_failures].compact
100
129
  end
101
130
 
102
131
  def ok?
@@ -110,7 +139,8 @@ module Seaports
110
139
  section("Removed", removed.map { |locode| "`#{locode}` #{@before[locode][:name]}" }, limit: nil),
111
140
  section("Added", added.map { |locode| "`#{locode}` #{@after[locode][:name]}" }),
112
141
  section("Renamed", renamed.map { |locode, was, now| "`#{locode}` #{was} → #{now}" }),
113
- section("Coordinates", repositioned.map { |locode, name, kind| "`#{locode}` #{name} — #{POSITION_WORDS.fetch(kind)}" })
142
+ section("Coordinates", repositioned.map { |locode, name, kind| "`#{locode}` #{name} — #{POSITION_WORDS.fetch(kind)}" }),
143
+ section("Subdivision", resubdivided.map { |locode, name, was, now| "`#{locode}` #{name}: #{path_words(was)} → #{path_words(now)}" })
114
144
  ].compact.join("\n\n")
115
145
  end
116
146
 
@@ -125,11 +155,32 @@ module Seaports
125
155
  def headline
126
156
  "#{@after.size} sea ports (#{@before.size} before): " \
127
157
  "#{added.size} added, #{removed.size} removed, " \
128
- "#{renamed.size} renamed, #{repositioned.size} repositioned."
158
+ "#{renamed.size} renamed, #{repositioned.size} repositioned, " \
159
+ "#{resubdivided.size} with a new subdivision (#{reassigned.size} moved to another principal one)."
129
160
  end
130
161
 
162
+ # A real refresh moves a handful of ports between regions, when UNECE
163
+ # corrects an entry or ISO redraws a country. Moving more than this share
164
+ # of the placed ports at once means a source reshuffled its codes.
165
+ MAXIMUM_REASSIGNMENT = 0.02
166
+
131
167
  private
132
168
 
169
+ def path_words(path)
170
+ path.empty? ? "none" : path.split.join(" › ")
171
+ end
172
+
173
+ def reassignment_failure
174
+ placed = regioned_count(@before)
175
+ return nil if placed.zero?
176
+
177
+ share = reassigned.size.to_f / placed
178
+ return nil if share <= MAXIMUM_REASSIGNMENT
179
+
180
+ "#{reassigned.size} ports moved to another principal subdivision, past the #{(MAXIMUM_REASSIGNMENT * 100).round}% limit: " \
181
+ "a source reshuffled its codes."
182
+ end
183
+
133
184
  def common
134
185
  @common ||= (@before.keys & @after.keys).sort
135
186
  end
@@ -149,6 +200,25 @@ module Seaports
149
200
  "The table shrank by #{(shrinkage * 100).round(1)}%, past the #{(MAXIMUM_SHRINKAGE * 100).round}% limit."
150
201
  end
151
202
 
203
+ # Losing subdivisions in bulk means the ISO 3166-2 list failed to load or
204
+ # UNECE dropped the column, not that ports moved country. A table that
205
+ # had none before (every table before this column existed) has nothing
206
+ # to lose.
207
+ def region_failure
208
+ was = regioned_count(@before)
209
+ return nil if was.zero?
210
+
211
+ now = regioned_count(@after)
212
+ shrinkage = (was - now).to_f / was
213
+ return nil if shrinkage <= MAXIMUM_SHRINKAGE
214
+
215
+ "Ports with a principal subdivision fell from #{was} to #{now}, past the #{(MAXIMUM_SHRINKAGE * 100).round}% limit."
216
+ end
217
+
218
+ def regioned_count(ports)
219
+ ports.count { |_, port| !port[:path].to_s.empty? }
220
+ end
221
+
152
222
  def anchor_failures
153
223
  ANCHORS.reject { |locode| @after.key?(locode) }
154
224
  .map { |locode| "`#{locode}` is gone, and a port that large does not disappear." }
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Seaports
6
+ # ISO 3166-2, for turning UN/LOCODE's subdivision column into codes other
7
+ # datasets share.
8
+ #
9
+ # UN/LOCODE files Hamburg under "HH" and Felixstowe under "SFK". Read with
10
+ # the country in front, both are ISO 3166-2 codes, but at different levels:
11
+ # DE-HH is a German state, GB-SFK an English county. Public holiday
12
+ # calendars, address geocoders and most other datasets that split a country
13
+ # work at the top level, the principal subdivision: DE-HH, and GB-ENG for
14
+ # Felixstowe. ISO publishes which code sits inside which, so the climb from
15
+ # county to nation is a lookup, not a guess.
16
+ #
17
+ # Build-time only. The source is Debian's iso-codes, the ISO 3166-2 list
18
+ # most Linux systems ship, and nothing at runtime reads it: the resolved
19
+ # codes are written into the table.
20
+ class Subdivisions
21
+ SOURCE_URL = "https://salsa.debian.org/iso-codes-team/iso-codes/-/raw/main/data/iso_3166-2.json"
22
+
23
+ # A chain longer than this is a loop in the data, not a real hierarchy:
24
+ # ISO goes three levels deep at most.
25
+ MAX_DEPTH = 5
26
+
27
+ def self.parse(json)
28
+ new(JSON.parse(json).fetch("3166-2"))
29
+ end
30
+
31
+ def initialize(entries)
32
+ @parents = entries.to_h { |entry| [entry.fetch("code"), entry["parent"]] }
33
+ end
34
+
35
+ # "DE-HH" for ("DE", "HH"), or nil when the pair is not an ISO 3166-2
36
+ # code. UN/LOCODE carries a few codes ISO has retired or never had, and a
37
+ # code nobody else recognises is worse than none.
38
+ def code(country, subdivision)
39
+ return nil if subdivision.to_s.strip.empty?
40
+
41
+ candidate = "#{country.to_s.strip.upcase}-#{subdivision.to_s.strip.upcase}"
42
+ @parents.key?(candidate) ? candidate : nil
43
+ end
44
+
45
+ # +code+ and every subdivision above it, most specific first:
46
+ # ["FR-67", "FR-6AE", "FR-GES"] for Bas-Rhin, ["DE-HH"] for Hamburg.
47
+ #
48
+ # The whole chain rather than just its top, because ISO's top level is not
49
+ # always the one that matters. Alsace-Moselle (FR-6AE) has holidays the
50
+ # rest of Grand Est (FR-GES) does not, and Indonesia's top level is island
51
+ # groups, above the provinces that run anything. A caller matches on
52
+ # whichever level its own data uses.
53
+ #
54
+ # Empty for nil, and for a chain that breaks (a parent the list does not
55
+ # hold) or loops: a guessed top would be believed.
56
+ def path(code)
57
+ return [] if code.nil?
58
+
59
+ chain = [code]
60
+ MAX_DEPTH.times do
61
+ parent = @parents[chain.last]
62
+ return chain if parent.nil?
63
+ return [] unless @parents.key?(parent)
64
+
65
+ chain << parent
66
+ end
67
+ []
68
+ end
69
+
70
+ # The top of +code+'s path: GB-ENG for GB-SFK, ES-VC for ES-V, DE-HH for
71
+ # DE-HH itself. Nil when the path is empty.
72
+ def principal(code)
73
+ path(code).last
74
+ end
75
+
76
+ def size
77
+ @parents.size
78
+ end
79
+ end
80
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Seaports
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
 
6
6
  # The UN/LOCODE edition the shipped table was built from. UNECE republishes
7
7
  # twice a year and names each release by year and half — "2024-2" is the
data/lib/seaports.rb CHANGED
@@ -38,7 +38,31 @@ require_relative "seaports/version"
38
38
  module Seaports
39
39
  TABLE_PATH = File.expand_path("../data/un_locode_seaports.csv", __dir__)
40
40
 
41
- Port = Data.define(:locode, :name, :coordinates)
41
+ # +subdivision+ is the ISO 3166-2 code of the area the port is in, at
42
+ # whatever level its source chose: "DE-HH" for Hamburg, "GB-SFK" (Suffolk)
43
+ # for Felixstowe. +subdivision_path+ is that code and every ISO subdivision
44
+ # above it, most specific first: ["GB-SFK", "GB-ENG"], or ["FR-67",
45
+ # "FR-6AE", "FR-GES"] for Strasbourg. +principal_subdivision+ is the last of
46
+ # them, ISO's top level.
47
+ #
48
+ # To join a port to other data, match on whichever level of the path that
49
+ # data uses. The top is usually right (German states, UK nations, Spanish
50
+ # communities) but not always: Alsace-Moselle (FR-6AE) has its own
51
+ # holidays inside Grand Est, and Indonesia's top level is island groups,
52
+ # above its provinces.
53
+ #
54
+ # The code comes from UN/LOCODE where it gives one, from the port's
55
+ # coordinates on Natural Earth's map where it does not, and from a reviewed
56
+ # list for ports neither places (Valencia, Barcelona). Nil, and an empty
57
+ # path, when none of the three does.
58
+ #
59
+ # The three were added in 1.2.0 with defaults, so a Port built from
60
+ # locode, name and coordinates alone, as before, still works.
61
+ Port = Data.define(:locode, :name, :coordinates, :subdivision, :principal_subdivision, :subdivision_path) do
62
+ def initialize(locode:, name:, coordinates:, subdivision: nil, principal_subdivision: nil, subdivision_path: [])
63
+ super
64
+ end
65
+ end
42
66
 
43
67
  class << self
44
68
  # Nil for anything the table does not hold, which callers must handle:
@@ -62,6 +86,23 @@ module Seaports
62
86
  find(locode)&.coordinates
63
87
  end
64
88
 
89
+ # "GB-SFK" for Felixstowe, or nil. See Port.
90
+ def subdivision(locode)
91
+ find(locode)&.subdivision
92
+ end
93
+
94
+ # "GB-ENG" for Felixstowe, "DE-HH" for Hamburg, or nil. See Port.
95
+ def principal_subdivision(locode)
96
+ find(locode)&.principal_subdivision
97
+ end
98
+
99
+ # ["GB-SFK", "GB-ENG"] for Felixstowe, [] for a port nothing places or a
100
+ # code the table does not hold. The codes to hand a holiday calendar that
101
+ # matches on any level: Trackberry::Holidays.holiday?(date, path).
102
+ def subdivision_path(locode)
103
+ find(locode)&.subdivision_path || []
104
+ end
105
+
65
106
  # Every port in the table, ordered by locode. Materialised on each call
66
107
  # rather than handed out from the cache, so a caller cannot mutate the
67
108
  # table everyone else reads.
@@ -104,7 +145,9 @@ module Seaports
104
145
  locode = normalize(row["locode"])
105
146
  next if locode.nil? || blank?(row["name"])
106
147
 
107
- ports[locode] = Port.new(locode: locode, name: row["name"], coordinates: point(row))
148
+ ports[locode] = Port.new(locode: locode, name: row["name"], coordinates: point(row),
149
+ subdivision: presence(row["subdivision"]), principal_subdivision: presence(row["principal_subdivision"]),
150
+ subdivision_path: row["subdivision_path"].to_s.split.freeze)
108
151
  end
109
152
  end
110
153
 
@@ -117,5 +160,9 @@ module Seaports
117
160
  def blank?(value)
118
161
  value.to_s.strip.empty?
119
162
  end
163
+
164
+ def presence(value)
165
+ blank?(value) ? nil : value.strip
166
+ end
120
167
  end
121
168
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: seaports
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Édouard Brière
@@ -24,10 +24,10 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '3.3'
26
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.
27
+ Every sea port UNECE publishes a UN/LOCODE for, as a lookup table that
28
+ makes no network calls. Turns the bare codes in an AIS or carrier tracking
29
+ feed back into port names and coordinates. The table is regenerated from
30
+ each UN/LOCODE release and shipped inside the gem.
31
31
  email:
32
32
  - edouard.briere@gmail.com
33
33
  executables: []
@@ -39,8 +39,10 @@ files:
39
39
  - README.md
40
40
  - data/un_locode_seaports.csv
41
41
  - lib/seaports.rb
42
+ - lib/seaports/boundaries.rb
42
43
  - lib/seaports/builder.rb
43
44
  - lib/seaports/diff.rb
45
+ - lib/seaports/subdivisions.rb
44
46
  - lib/seaports/version.rb
45
47
  homepage: https://github.com/Trackberry/seaports
46
48
  licenses: