seaports 1.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +65 -6
- data/data/un_locode_seaports.csv +17528 -17528
- data/lib/seaports/boundaries.rb +176 -0
- data/lib/seaports/builder.rb +95 -4
- data/lib/seaports/diff.rb +75 -5
- data/lib/seaports/subdivisions.rb +80 -0
- data/lib/seaports/version.rb +1 -1
- data/lib/seaports.rb +49 -2
- metadata +3 -1
|
@@ -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
|
data/lib/seaports/builder.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/seaports/version.rb
CHANGED
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
|
-
|
|
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.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Édouard Brière
|
|
@@ -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:
|