cocina_display 2.11.0 → 2.12.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/lib/cocina_display/concerns/geospatial.rb +6 -1
- data/lib/cocina_display/geospatial.rb +188 -50
- data/lib/cocina_display/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16e21603fafbb47fe2471e436a256e19eac1d3b69acb85c1bb246d3f39e0d06c
|
|
4
|
+
data.tar.gz: '0898b33cdbf5b6c0b9f1f4d9fcdea3b4492d8b265b67fa90a08e14394e476bd2'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ddc270e2faa1baa9fbcbbbeaaf8f6fba3ae030e5b6be5741ed407c398a1e1211daaeefdddeeb60ae056748a963c6b1f7c0017b745cc2a2a83d7dcf97de062064
|
|
7
|
+
data.tar.gz: d56dc751d95514b82c2cd943a21ab3befee10adeddb69546796a30e28513a9ac4bf3bc6c4e212b5fc0d0d585a9f34ea339e4c6ccfd5224550e6df35eb575c531
|
|
@@ -14,6 +14,8 @@ module CocinaDisplay
|
|
|
14
14
|
# All valid coordinate data formatted for indexing into a Solr RPT field.
|
|
15
15
|
# @note This type of field accommodates both points and bounding boxes.
|
|
16
16
|
# @note In WKT, points have longitude first, unlike {coordinates_as_point}.
|
|
17
|
+
# @note A box crossing the antimeridian is split at the date line, so it is
|
|
18
|
+
# rendered as a MULTIPOLYGON of its two halves.
|
|
17
19
|
# @see https://solr.apache.org/guide/solr/latest/query-guide/spatial-search.html#rpt
|
|
18
20
|
# @return [Array<String>]
|
|
19
21
|
# @example ["POINT(-118.2437 34.0522)", "POLYGON((-118.2437 34.0522, -118.2437 34.1996, -117.9522 34.1996, -117.9522 34.0522, -118.2437 34.0522))"]
|
|
@@ -23,6 +25,7 @@ module CocinaDisplay
|
|
|
23
25
|
|
|
24
26
|
# All valid coordinate data formatted for indexing into a Solr BBoxField.
|
|
25
27
|
# @note Points are not included since they can't be represented as a box.
|
|
28
|
+
# @note West is greater than east for a box crossing the antimeridian.
|
|
26
29
|
# @see https://solr.apache.org/guide/solr/latest/query-guide/spatial-search.html#bboxfield
|
|
27
30
|
# @return [Array<String>]
|
|
28
31
|
# @example ["ENVELOPE(-118.2437, -117.9522, 34.1996, 34.0522)"]
|
|
@@ -40,8 +43,10 @@ module CocinaDisplay
|
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
# All valid coordinate data formatted as bounding boxes.
|
|
43
|
-
# Format is [[
|
|
46
|
+
# Format is [[south, west], [north, east]].
|
|
44
47
|
# @note Points are not included since they can't be represented as a box.
|
|
48
|
+
# @note For a box crossing the antimeridian, east is carried past 180 so that
|
|
49
|
+
# the pair still reads southwest to northeast.
|
|
45
50
|
# @return [Array<Array<Array<Float>>>]
|
|
46
51
|
def coordinates_as_bbox
|
|
47
52
|
coordinate_objects.map(&:as_bbox).compact.uniq
|
|
@@ -20,15 +20,17 @@ module CocinaDisplay
|
|
|
20
20
|
# @return [Coordinates, nil]
|
|
21
21
|
def from_structured_values(structured_values)
|
|
22
22
|
if structured_values.size == 2
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
Point.from_coords(
|
|
24
|
+
lat: structured_value(structured_values, "latitude"),
|
|
25
|
+
lng: structured_value(structured_values, "longitude")
|
|
26
|
+
)
|
|
26
27
|
elsif structured_values.size == 4
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
BoundingBox.from_coords(
|
|
29
|
+
west: structured_value(structured_values, "west"),
|
|
30
|
+
east: structured_value(structured_values, "east"),
|
|
31
|
+
north: structured_value(structured_values, "north"),
|
|
32
|
+
south: structured_value(structured_values, "south")
|
|
33
|
+
)
|
|
32
34
|
end
|
|
33
35
|
end
|
|
34
36
|
|
|
@@ -54,6 +56,36 @@ module CocinaDisplay
|
|
|
54
56
|
# Use the matching parser to parse the string
|
|
55
57
|
parser_class.parse(match_str)
|
|
56
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Find a single coordinate value of the given type in structured data.
|
|
63
|
+
# @param [Array<Hash>] structured_values
|
|
64
|
+
# @param [String] type like "west" or "latitude"
|
|
65
|
+
# @return [String, nil]
|
|
66
|
+
def structured_value(structured_values, type)
|
|
67
|
+
value = structured_values.find { |v| v["type"] == type }&.dig("value")
|
|
68
|
+
normalize_value(value) if value.present?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Standardize a single coordinate value so that Geo::Coord can parse it.
|
|
72
|
+
# Chooses a normalizer based on the string format, since structured values
|
|
73
|
+
# can be decimal degrees or DMS, including the packed MARC 034 form.
|
|
74
|
+
# @param [String] value
|
|
75
|
+
# @return [String, nil] nil if the format isn't recognized
|
|
76
|
+
# @example "W1210000" becomes "121°0′0″W"
|
|
77
|
+
def normalize_value(value)
|
|
78
|
+
# Remove all whitespace for easier matching/parsing
|
|
79
|
+
match_str = value.gsub(/\s+/, "")
|
|
80
|
+
|
|
81
|
+
# Try each normalizer in order until one matches; bail out if none do
|
|
82
|
+
normalizer_class = [
|
|
83
|
+
DMSCoordinateNormalizer,
|
|
84
|
+
DecimalCoordinateNormalizer
|
|
85
|
+
].find { |normalizer| normalizer.supports?(match_str) }
|
|
86
|
+
|
|
87
|
+
normalizer_class&.normalize_coord(match_str)
|
|
88
|
+
end
|
|
57
89
|
end
|
|
58
90
|
|
|
59
91
|
protected
|
|
@@ -146,35 +178,72 @@ module CocinaDisplay
|
|
|
146
178
|
end
|
|
147
179
|
end
|
|
148
180
|
|
|
149
|
-
# A bounding box defined by
|
|
181
|
+
# A bounding box defined by its southwest and northeast corner points.
|
|
182
|
+
# The box can wrap east-west across the antimeridian, in which case its west
|
|
183
|
+
# edge is numerically east of its east edge. Both Solr's rectangle syntax and
|
|
184
|
+
# GeoJSON spell a crossing box that way, as do MARC 034 $d/$e, which are the
|
|
185
|
+
# westernmost and easternmost longitudes rather than the minimum and maximum.
|
|
186
|
+
# @see https://datatracker.ietf.org/doc/html/rfc7946#section-5.2
|
|
150
187
|
class BoundingBox < Coordinates
|
|
151
|
-
attr_reader :
|
|
188
|
+
attr_reader :southwest, :northeast
|
|
152
189
|
|
|
153
190
|
# Construct a BoundingBox from west, east, north, and south string values.
|
|
191
|
+
# West and east are used as given, so a box that crosses the antimeridian
|
|
192
|
+
# is preserved instead of rejected.
|
|
154
193
|
# @param [String] west western longitude
|
|
155
194
|
# @param [String] east eastern longitude
|
|
156
195
|
# @param [String] north northern latitude
|
|
157
196
|
# @param [String] south southern latitude
|
|
158
197
|
# @return [BoundingBox, nil] nil if parsing fails
|
|
159
198
|
def self.from_coords(west:, east:, north:, south:)
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
southwest = Geo::Coord.parse("#{south}, #{west}")
|
|
200
|
+
northeast = Geo::Coord.parse("#{north}, #{east}")
|
|
162
201
|
|
|
163
202
|
# Must be parsable
|
|
164
|
-
return unless
|
|
203
|
+
return unless southwest && northeast
|
|
204
|
+
|
|
205
|
+
# A box can wrap east-west, but never north-south
|
|
206
|
+
return if southwest.lat > northeast.lat
|
|
165
207
|
|
|
166
|
-
|
|
167
|
-
|
|
208
|
+
new(southwest: southwest, northeast: northeast)
|
|
209
|
+
end
|
|
168
210
|
|
|
169
|
-
|
|
211
|
+
# Construct a BoundingBox from two corner Geo::Coord points.
|
|
212
|
+
# @param [Geo::Coord] southwest
|
|
213
|
+
# @param [Geo::Coord] northeast
|
|
214
|
+
def initialize(southwest:, northeast:)
|
|
215
|
+
@southwest = southwest
|
|
216
|
+
@northeast = northeast
|
|
170
217
|
end
|
|
171
218
|
|
|
172
|
-
#
|
|
173
|
-
# @
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
219
|
+
# The westernmost longitude of the box.
|
|
220
|
+
# @return [BigDecimal]
|
|
221
|
+
def west
|
|
222
|
+
southwest.lng
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The easternmost longitude of the box.
|
|
226
|
+
# @return [BigDecimal]
|
|
227
|
+
def east
|
|
228
|
+
northeast.lng
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The northernmost latitude of the box.
|
|
232
|
+
# @return [BigDecimal]
|
|
233
|
+
def north
|
|
234
|
+
northeast.lat
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The southernmost latitude of the box.
|
|
238
|
+
# @return [BigDecimal]
|
|
239
|
+
def south
|
|
240
|
+
southwest.lat
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# True if the box wraps east-west across the antimeridian.
|
|
244
|
+
# @return [Boolean]
|
|
245
|
+
def crosses_antimeridian?
|
|
246
|
+
west > east
|
|
178
247
|
end
|
|
179
248
|
|
|
180
249
|
# Format for display in DMS format, adapted from ISO 6709 standard.
|
|
@@ -183,33 +252,31 @@ module CocinaDisplay
|
|
|
183
252
|
# @return [String]
|
|
184
253
|
# @example "118°14′37″W -- 117°56′55″W / 34°03′08″N -- 34°11′59″N"
|
|
185
254
|
def to_s
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
"#{
|
|
255
|
+
south_str, west_str = format_point(southwest)
|
|
256
|
+
north_str, east_str = format_point(northeast)
|
|
257
|
+
"#{west_str} -- #{east_str} / #{north_str} -- #{south_str}"
|
|
189
258
|
end
|
|
190
259
|
|
|
191
260
|
# Format using the Well-Known Text (WKT) representation.
|
|
192
261
|
# @note Limits decimals to 6 places.
|
|
262
|
+
# @note A box crossing the antimeridian is split into two polygons at the
|
|
263
|
+
# date line, so that every longitude stays within bounds.
|
|
193
264
|
# @see https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
|
|
265
|
+
# @see https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.9
|
|
194
266
|
# @return [String]
|
|
195
267
|
def as_wkt
|
|
196
|
-
"POLYGON((
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
max_point.lng, max_point.lat,
|
|
200
|
-
min_point.lng, max_point.lat,
|
|
201
|
-
min_point.lng, min_point.lat
|
|
202
|
-
]
|
|
268
|
+
return "POLYGON(#{ring(west, east)})" unless crosses_antimeridian?
|
|
269
|
+
|
|
270
|
+
"MULTIPOLYGON((#{ring(west, 180)}), (#{ring(-180, east)}))"
|
|
203
271
|
end
|
|
204
272
|
|
|
205
273
|
# Format using the CQL ENVELOPE representation.
|
|
206
274
|
# @note Limits decimals to 6 places.
|
|
275
|
+
# @note West is greater than east for a box crossing the antimeridian.
|
|
207
276
|
# @example "ENVELOPE(-118.2437, -117.9522, 34.1996, 34.0522)"
|
|
208
277
|
# @return [String]
|
|
209
278
|
def as_envelope
|
|
210
|
-
"ENVELOPE(%.6f, %.6f, %.6f, %.6f)" % [
|
|
211
|
-
min_point.lng, max_point.lng, max_point.lat, min_point.lat
|
|
212
|
-
]
|
|
279
|
+
"ENVELOPE(%.6f, %.6f, %.6f, %.6f)" % [west, east, north, south]
|
|
213
280
|
end
|
|
214
281
|
|
|
215
282
|
# The box center point as a space-separated x y (longitude latitude) pair.
|
|
@@ -217,18 +284,43 @@ module CocinaDisplay
|
|
|
217
284
|
# @example "-118.2437 34.0522"
|
|
218
285
|
# @return [String]
|
|
219
286
|
def as_point
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"%.6f %.6f" % [center.lng, center.lat]
|
|
287
|
+
center_lng = (west + unwrapped_east) / 2
|
|
288
|
+
center_lng -= 360 if center_lng > 180
|
|
289
|
+
"%.6f %.6f" % [center_lng, (south + north) / 2]
|
|
224
290
|
end
|
|
225
291
|
|
|
226
292
|
# Format the bounding box as an array of two coordinate pairs [[S, W], [N, E]].
|
|
227
293
|
# @note Limits decimals to 6 places.
|
|
294
|
+
# @note For a box crossing the antimeridian, east is carried past 180 so that
|
|
295
|
+
# the pair still reads southwest to northeast.
|
|
228
296
|
# @return [Array<Array<Float>>]
|
|
229
|
-
# @example [[-118.2437, 34.
|
|
297
|
+
# @example [[34.0522, -118.2437], [34.1996, -117.9522]]
|
|
230
298
|
def as_bbox
|
|
231
|
-
[[
|
|
299
|
+
[[south, west], [north, unwrapped_east]]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
# The east edge as a continuous longitude, carried past 180 if the box
|
|
305
|
+
# crosses the antimeridian.
|
|
306
|
+
# @return [BigDecimal]
|
|
307
|
+
def unwrapped_east
|
|
308
|
+
crosses_antimeridian? ? east + 360 : east
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# A closed WKT linear ring for the box, spanning the given longitudes.
|
|
312
|
+
# @note Limits decimals to 6 places.
|
|
313
|
+
# @param [Numeric] west_lng
|
|
314
|
+
# @param [Numeric] east_lng
|
|
315
|
+
# @return [String]
|
|
316
|
+
def ring(west_lng, east_lng)
|
|
317
|
+
"(%.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f)" % [
|
|
318
|
+
west_lng, south,
|
|
319
|
+
east_lng, south,
|
|
320
|
+
east_lng, north,
|
|
321
|
+
west_lng, north,
|
|
322
|
+
west_lng, south
|
|
323
|
+
]
|
|
232
324
|
end
|
|
233
325
|
end
|
|
234
326
|
|
|
@@ -313,12 +405,58 @@ module CocinaDisplay
|
|
|
313
405
|
matches = input_str.match(self::PATTERN)
|
|
314
406
|
return unless matches
|
|
315
407
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
408
|
+
west = normalize_coord(matches[:west])
|
|
409
|
+
east = normalize_coord(matches[:east])
|
|
410
|
+
south = normalize_coord(matches[:south])
|
|
411
|
+
north = normalize_coord(matches[:north])
|
|
412
|
+
|
|
413
|
+
BoundingBox.from_coords(west: west, east: east, north: north, south: south)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
320
416
|
|
|
321
|
-
|
|
417
|
+
# Base class for normalizers that standardize a single coordinate value, as
|
|
418
|
+
# found in Cocina structured values, so that Geo::Coord can parse it.
|
|
419
|
+
# Subclasses define a PATTERN and mix in a parser module for normalize_coord.
|
|
420
|
+
class CoordinateNormalizer < CoordinatesParser
|
|
421
|
+
# Move a trailing hemisphere letter to the front, since that is where the
|
|
422
|
+
# parser normalizers expect it.
|
|
423
|
+
# @example "121.5W" becomes "W121.5"
|
|
424
|
+
# @param [String] value
|
|
425
|
+
# @return [String]
|
|
426
|
+
def self.hemisphere_first(value)
|
|
427
|
+
value.sub(/\A(.+?)([NESW])\z/, '\2\1')
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Normalizes DMS values, including the packed form used in MARC 034 subfields.
|
|
432
|
+
# @example W1210000
|
|
433
|
+
# @example 121°14′48″W
|
|
434
|
+
class DMSCoordinateNormalizer < CoordinateNormalizer
|
|
435
|
+
include DMSParser
|
|
436
|
+
|
|
437
|
+
# Either DMS punctuation, or a hemisphere paired with packed digits.
|
|
438
|
+
PATTERN = /[°⁰º′ʹ'″ʺ"]|\A[NESW]\d{4,}\z|\A\d{4,}[NESW]\z/
|
|
439
|
+
|
|
440
|
+
# @param [String] value
|
|
441
|
+
# @return [String, nil]
|
|
442
|
+
def self.normalize_coord(value)
|
|
443
|
+
super(hemisphere_first(value))
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Normalizes decimal degree values, either signed or paired with a hemisphere.
|
|
448
|
+
# @note Degrees are limited to 3 digits so that packed DMS isn't read as decimal.
|
|
449
|
+
# @example -121.24658
|
|
450
|
+
# @example W126.04
|
|
451
|
+
class DecimalCoordinateNormalizer < CoordinateNormalizer
|
|
452
|
+
include DecimalParser
|
|
453
|
+
|
|
454
|
+
PATTERN = /\A[NESW+-]?\d{1,3}(?:\.\d+)?[NESW]?\z/
|
|
455
|
+
|
|
456
|
+
# @param [String] value
|
|
457
|
+
# @return [String]
|
|
458
|
+
def self.normalize_coord(value)
|
|
459
|
+
super(hemisphere_first(value))
|
|
322
460
|
end
|
|
323
461
|
end
|
|
324
462
|
|
|
@@ -342,7 +480,7 @@ module CocinaDisplay
|
|
|
342
480
|
class DMSBoundingBoxParser < BoundingBoxParser
|
|
343
481
|
include DMSParser
|
|
344
482
|
|
|
345
|
-
PATTERN = /(?<
|
|
483
|
+
PATTERN = /(?<west>.+?)-+(?<east>.+)\/(?<north>.+?)-+(?<south>.+)/
|
|
346
484
|
end
|
|
347
485
|
|
|
348
486
|
# Format that pairs hemispheres with decimal degrees.
|
|
@@ -350,21 +488,21 @@ module CocinaDisplay
|
|
|
350
488
|
class DecimalBoundingBoxParser < BoundingBoxParser
|
|
351
489
|
include DecimalParser
|
|
352
490
|
|
|
353
|
-
PATTERN = /(?<
|
|
491
|
+
PATTERN = /(?<west>[0-9.EW]+?)-+(?<east>[0-9.EW]+)\/(?<north>[0-9.NS]+?)-+(?<south>[0-9.NS]+)/
|
|
354
492
|
end
|
|
355
493
|
|
|
356
494
|
# DMS-format data that appears to come from MARC 034 subfields.
|
|
357
495
|
# @see https://www.oclc.org/bibformats/en/0xx/034.html
|
|
358
496
|
# @example $dW0963700$eW0900700$fN0433000$gN040220
|
|
359
497
|
class MarcDMSBoundingBoxParser < DMSBoundingBoxParser
|
|
360
|
-
PATTERN = /\$d(?<
|
|
498
|
+
PATTERN = /\$d(?<west>[WENS].+)\$e(?<east>[WENS].+)\$f(?<north>[WENS].+)\$g(?<south>[WENS].+)/
|
|
361
499
|
end
|
|
362
500
|
|
|
363
501
|
# Decimal degree format data that appears to come from MARC 034 subfields.
|
|
364
502
|
# @see https://www.oclc.org/bibformats/en/0xx/034.html
|
|
365
503
|
# @example $d-112.0785250$e-111.6012719$f037.6516503$g036.8583209
|
|
366
504
|
class MarcDecimalBoundingBoxParser < DecimalBoundingBoxParser
|
|
367
|
-
PATTERN = /\$d(?<
|
|
505
|
+
PATTERN = /\$d(?<west>[0-9.-]+)\$e(?<east>[0-9.-]+)\$f(?<north>[0-9.-]+)\$g(?<south>[0-9.-]+)/
|
|
368
506
|
end
|
|
369
507
|
end
|
|
370
508
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cocina_display
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nick Budak
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2026-08-11 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: janeway-jsonpath
|
|
@@ -326,7 +326,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
326
326
|
- !ruby/object:Gem::Version
|
|
327
327
|
version: '0'
|
|
328
328
|
requirements: []
|
|
329
|
-
rubygems_version:
|
|
329
|
+
rubygems_version: 3.6.2
|
|
330
330
|
specification_version: 4
|
|
331
331
|
summary: Helpers for rendering Cocina metadata
|
|
332
332
|
test_files: []
|