Class: GeographicItem

Inherits:
ApplicationRecord show all
Includes:
Housekeeping::Timestamps, Housekeeping::Users, Shared::HasPapertrail, Shared::IsData, Shared::SharedAcrossProjects
Defined in:
app/models/geographic_item.rb

Overview

A GeographicItem describes a position, path, or area on the globe, generally associated with a geographic_area (through a geographic_area_geographic_item entry), a gazetteer, or a georeference.

Key methods in this giant library

‘#geo_object` - return a RGEO object representation

Constant Summary collapse

SHAPE_TYPES =
[
  :point,
  :line_string,
  :polygon,
  :multi_point,
  :multi_line_string,
  :multi_polygon,
  :geometry_collection
].freeze
ANTI_MERIDIAN =

ANTI_MERIDIAN = ‘0X0102000020E61000000200000000000000008066400000000000405640000000000080664000000000004056C0’

'LINESTRING (180 89.0, 180 -89.0)'.freeze

Instance Attribute Summary collapse

Attributes included from Housekeeping::Users

#by

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Shared::IsData

#errors_excepting, #full_error_messages_excepting, #identical, #is_community?, #is_destroyable?, #is_editable?, #is_in_use?, #is_in_users_projects?, #metamorphosize, #similar

Methods included from Shared::HasPapertrail

#attribute_updated, #attribute_updater, #detect_version

Methods included from Housekeeping::Users

#set_created_by_id, #set_updated_by_id

Methods inherited from ApplicationRecord

transaction_with_retry

Instance Attribute Details

#cached_total_areaNumeric

Returns if polygon-based the value of the enclosed area in square meters.

Returns:

  • (Numeric)

    if polygon-based the value of the enclosed area in square meters



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
# File 'app/models/geographic_item.rb', line 18

class GeographicItem < ApplicationRecord
  include Housekeeping::Users
  include Housekeeping::Timestamps
  include Shared::HasPapertrail
  include Shared::IsData
  include Shared::SharedAcrossProjects

  # @return [Boolean, RGeo object]
  # @params value [Hash in GeoJSON format] ?!
  # TODO: WHY! boolean not nil, or object
  # Used to build geographic items from a shape [ of what class ] !?
  attr_accessor :shape

  # @return [Boolean]
  #   When true cached values are not built
  attr_accessor :no_cached

  SHAPE_TYPES = [
    :point,
    :line_string,
    :polygon,
    :multi_point,
    :multi_line_string,
    :multi_polygon,
    :geometry_collection
  ].freeze

  # ANTI_MERIDIAN = '0X0102000020E61000000200000000000000008066400000000000405640000000000080664000000000004056C0'
  ANTI_MERIDIAN = 'LINESTRING (180 89.0, 180 -89.0)'.freeze

  # TODO Remove once `type` for GI STI has been deleted (it's currently there to
  # ease switching branches during development).
  self.inheritance_column = nil

  has_many :cached_map_items, inverse_of: :geographic_item

  has_many :geographic_areas_geographic_items, dependent: :destroy, inverse_of: :geographic_item
  has_many :geographic_areas, through: :geographic_areas_geographic_items
  has_many :geographic_area_types, through: :geographic_areas
  has_many :parent_geographic_areas, through: :geographic_areas, source: :parent

  has_many :georeferences, inverse_of: :geographic_item
  has_many :georeferences_through_error_geographic_item, class_name: 'Georeference', foreign_key: :error_geographic_item_id, inverse_of: :error_geographic_item
  has_many :collecting_events_through_georeferences, through: :georeferences, source: :collecting_event
  has_many :collecting_events_through_georeference_error_geographic_item,
    through: :georeferences_through_error_geographic_item, source: :collecting_event

  has_many :gazetteers, inverse_of: :geographic_item

  validate :some_data_is_provided

  scope :include_collecting_event, -> { includes(:collecting_events_through_georeferences) }
  scope :geo_with_collecting_event, -> { joins(:collecting_events_through_georeferences) }
  scope :err_with_collecting_event, -> { joins(:georeferences_through_error_geographic_item) }

  scope :points, -> { where(shape_is_type(:point)) }
  scope :multi_points, -> { where(shape_is_type(:multi_point)) }
  scope :line_strings, -> { where(shape_is_type(:line_string)) }
  scope :multi_line_strings, -> { where(shape_is_type(:multi_line_string)) }
  scope :polygons, -> { where(shape_is_type(:polygon)) }
  scope :multi_polygons, -> { where(shape_is_type(:multi_polygon)) }
  scope :geometry_collections, -> { where(shape_is_type(:geometry_collection)) }

  # Retrieving a geography point requires instantiating that point using our
  # Gis::FACTORY, which itself normalizes longitudes, so this is really just
  # ensuring that the normalized longitude is what we'll see stored in the
  # database as well.
  # TODO if/when needed: multipoints and points/multipoints inside
  # geometry_collections also need normalizing (Gis::FACTORY normalizes
  # longitudes of all other shapes).
  before_save :normalize_point_longitude

  after_save :set_cached, unless: Proc.new {|n| n.no_cached || errors.any? }
  after_save :align_winding

  class << self

    # DEPRECATED, moved to ::Queries::GeographicItem
    def st_union(geographic_item_scope)
      select('ST_Union(geography::geometry) as st_union')
        .where(id: geographic_item_scope.pluck(:id))
    end

    def st_covers_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Covers',
        [
          geometry_cast(shape1),
          geometry_cast(shape2)
        ]
      )
    end

    # True for those shapes that cover shape.
    def superset_of_sql(shape)
      st_covers_sql(
        geography_as_geometry,
        shape
      )
    end

    # @return [Scope] of items covering the union of geographic_item_ids;
    # does not include any of geographic_item_ids
    def superset_of_union_of(*geographic_item_ids)
      where(
        superset_of_sql(
          items_as_one_geometry_sql(*geographic_item_ids)
        )
      )
      .not_ids(*geographic_item_ids)
    end

    def st_covered_by_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_CoveredBy',
        [
          geometry_cast(shape1),
          geometry_cast(shape2)
        ]
      )
    end

    # True for those shapes that are subsets of shape.
    def subset_of_sql(shape)
      st_covered_by_sql(
        geography_as_geometry,
        shape
      )
    end

    # Only called when shape crosses the anti-meridian
    # !! shape must be pre-shifted to longitude-range (0, 360) !!
    def subset_of_shifted_anti_meridian_shape_sql(shape)
      st_covered_by_sql(
        # All database geographic_items are (!! should be !!) stored in our
        # Gis::FACTORY-enforced longitude range (-180, 180), so always need to
        # be shifted in this case to the range (0, 360).
        st_shift_longitude_sql(geography_as_geometry),
        shape
      )
    end

    # Note: !! If the target GeographicItem#id crosses the anti-meridian then
    # you may/will get unexpected results.
    def subset_of_union_of_sql(*geographic_item_ids)
      subset_of_sql(
        items_as_one_geometry_sql(*geographic_item_ids)
      )
    end

    def st_distance_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Distance', [
          shape1,
          shape2
        ]
      )
    end

    def st_area_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Area', [
          shape
        ]
      )
    end

    # Intended here to be used as an aggregate function
    def st_union_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Union', [
          shape
        ]
      )
    end

    def st_dump_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Dump', [
          shape
        ]
      )
    end

    def st_collect_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Collect', [
          shape
        ]
      )
    end

    def st_is_valid_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_IsValid', [
          shape
        ]
      )
    end

    def st_is_valid_reason_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_IsValidReason', [
          shape
        ]
      )
    end

    def st_as_text_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_AsText', [
          shape
        ]
      )
    end

    def st_geometry_type(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_GeometryType', [
          geometry_cast(shape)
        ]
      )
    end

    def st_minimum_bounding_radius_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_MinimumBoundingRadius', [
          shape
        ]
      )
    end

    def st_as_geo_json_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_AsGeoJSON', [
          shape
        ]
      )
    end

    def st_geography_from_text_sql(wkt)
      wkt = quote_string(wkt)
      Arel::Nodes::NamedFunction.new(
        'ST_GeographyFromText', [
          Arel::Nodes.build_quoted(wkt),
        ]
      )
    end

    alias st_geog_from_text_sql st_geography_from_text_sql

    def st_geometry_from_text_sql(wkt)
      wkt = quote_string(wkt)
      Arel::Nodes::NamedFunction.new(
        'ST_GeometryFromText', [
          Arel::Nodes.build_quoted(wkt),
          Arel::Nodes.build_quoted(4326)
        ]
      )
    end

    alias st_geom_from_text_sql st_geometry_from_text_sql

    def st_centroid_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Centroid', [
          shape
        ]
      )
    end

    def st_buffer_sql(shape, distance, num_seg_quarter_circle: 8)
      Arel::Nodes::NamedFunction.new(
        'ST_Buffer', [
          geography_cast(shape),
          Arel::Nodes.build_quoted(distance),
          Arel::Nodes.build_quoted(num_seg_quarter_circle)
        ]
      )
    end

    # # !! Keep in mind that you may get different results depending on if the
    # inputs are geographies or geometries.
    def st_intersects_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Intersects', [
          shape1,
          shape2
        ]
      )
    end

    # !! Keep in mind that you may get different results depending on if the
    # inputs are geographies or geometries.
    def st_intersection_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Intersection', [
          shape1,
          shape2
        ]
      )
    end

    def st_shift_longitude_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_ShiftLongitude', [
          geometry_cast(shape)
        ]
      )
    end

    # True if the distance from shape1 to shape2 is less than `distance`. This
    # is a geography dwithin, distance is in meters.
    def st_dwithin_sql(shape1, shape2, distance)
      Arel::Nodes::NamedFunction.new(
        'ST_DWithin', [
          geography_cast(shape1),
          geography_cast(shape2),
          Arel::Nodes.build_quoted(distance)
        ]
      )
    end

    def st_as_lat_lon_text_sql(point_shape, format = '')
      Arel::Nodes::NamedFunction.new(
        'ST_AsLatLonText', [
          geometry_cast(point_shape),
          Arel::Nodes.build_quoted(format)
        ]
      )
    end

    # Returns valid shapes unchanged.
    # !! This will give the wrong result on anti-meridian-crossing shapes stored
    # in Gis::FACTORY coordinates, use anti_meridian_crossing_make_valid
    # instead in that case.
    def st_make_valid_sql(shape)
      # TODO add params once we're on GEOS >= 3.10, they're not used until then
      #params = "method=structure keepcollapsed=false"
      Arel::Nodes::NamedFunction.new(
        'ST_MakeValid', [
          geometry_cast(shape)
          #Arel::Nodes.build_quoted(params)
        ]
      )
    end

    # Assumes wkt crosses the anti-meridian.
    # !! Note you must apply St_ShiftLongitude to the return value to have the
    # correct interpretation of the return value's relation to the
    # anti-meridian (cf. anti_meridian_spec.rb).
    def anti_meridian_crossing_make_valid_sql(wkt)
      st_make_valid_sql(
        st_shift_longitude_sql(
          st_geom_from_text_sql(
            wkt
          )
        )
      )
    end

    def make_valid_non_anti_meridian_crossing_shape(wkt)
      if crosses_anti_meridian?(wkt)
        split_along_anti_meridian(wkt, make_valid: true)
      else
        wkb = select_value(
          st_make_valid_sql(
            st_geom_from_text_sql(
              wkt
            )
          )
        )

        ::Gis::FACTORY.parse_wkb(wkb)
      end
    end

    # @param [String] wkt
    # @return RGeo shape for wkt expressed as a union of pieces none of which
    # intersect the anti-meridian. Slightly lossy (has to be), and may turn
    # polygon into multi-polygon, etc.
    # Assumes wkt intersects the anti-meridian.
    def split_along_anti_meridian(wkt, make_valid: false)
      wkt = quote_string(wkt)
      # Intended to be the exterior of a tiny buffer around the anti-meridian,
      # expressed as two sheets/near-hemispheres that meet at long=0=360.
      anti_meridian_exterior = 'MULTIPOLYGON(
        ((0 -89.999999, 179.999999 -89.999999, 179.999999 89.999999, 0 89.999999, 0 -89.999999)),
        ((180.000001 -89.999999, 360 -89.999999, 360 89.999999, 180.000001 89.999999, 180.000001 -89.999999))
      )'

      s = make_valid ?
        anti_meridian_crossing_make_valid_sql(wkt) :
        st_shift_longitude_sql(st_geom_from_text_sql(wkt))

      wkb = select_value(
        st_intersection_sql(
          s,
          st_geom_from_text_sql(anti_meridian_exterior)
        )
      )

      ::Gis::FACTORY.parse_wkb(wkb)
    end

    # @param [String] wkt
    # @return [Boolean]
    #   whether or not the wkt intersects with the anti-meridian
    def crosses_anti_meridian?(wkt)
      wkt = quote_string(wkt)
      select_value(
        st_intersects_sql(
          st_geography_from_text_sql(wkt),
          st_geography_from_text_sql(ANTI_MERIDIAN)
        )
      )
    end

    # @param [String] wkt
    # @param [Integer] buffer distance
    # @return [Boolean]
    #   whether or not the radius-buffer of wkt-point intersects the
    #   anti-meridian
    def buffer_crosses_anti_meridian?(wkt, distance)
      wkt = quote_string(wkt)
      select_value(
        st_intersects_sql(
          st_buffer_sql(
            st_geography_from_text_sql(wkt),
            distance
          ),
          st_geography_from_text_sql(ANTI_MERIDIAN)
        )
      )
    end

    # Unused, kept for reference
    # @param [Integer] ids
    # @return [Boolean]
    #   whether or not any GeographicItem passed intersects the anti-meridian
    #   !! StrongParams security considerations This is our first line of
    #   defense against queries that define multiple shapes, one or more of
    #   which crosses the anti-meridian.  In this case the current TW strategy
    #   within the UI is to abandon the search, and prompt the user to
    #   refactor the query.
    def crosses_anti_meridian_by_id?(*ids)
      q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \
            'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN
      GeographicItem.find_by_sql(q).first.r
    end

    #
    # SQL fragments
    #

    # @param [Integer, String]
    # @return [SelectManager]
    #   a SQL select statement that returns the *geometry* for the
    #   geographic_item with the specified id
    def select_geometry_sql(geographic_item_id)
      arel_table
        .project(geography_as_geometry)
        .where(arel_table[:id].eq(geographic_item_id))
    end

    # @param [Integer, String]
    # @return [SelectManager]
    #   a SQL select statement that returns the geography for the
    #   geographic_item with the specified id
    def select_geography_sql(geographic_item_id)
      arel_table
        .project(arel_table[:geography])
        .where(arel_table[:id].eq(geographic_item_id))
    end

    # @param [Symbol] choice, either :latitude or :longitude
    # @return [Arel::Nodes::NamedFunction]
    #   a fragment returning either latitude or longitude columns
    def lat_long_sql(choice)
      return nil unless [:latitude, :longitude].include?(choice)
      f = 'D.DDDDDD'
      v = (choice == :latitude ? 1 : 2)

      split_part(
        st_as_lat_lon_text_sql(
          st_centroid_sql(geography_as_geometry),
          f
        ),
        ' ',
        v
      ).as(choice.to_s)
    end

    # @param [Integer] geographic_item_id
    # @param [Integer] radius in meters
    # @return [Scope] of shapes within distance of (i.e. whose
    #   distance-buffer intersects) geographic_item_id
    def within_radius_of_item_sql(geographic_item_id, radius)
      st_dwithin_sql(
        select_geography_sql(geographic_item_id),
        arel_table[:geography],
        radius
      )
    end

    def within_radius_of_item(geographic_item_id, radius)
      where(within_radius_of_item_sql(geographic_item_id, radius))
    end

    # @param [Integer] geographic_item_id
    # @param [Number] distance (in meters) (positive only?!)
    # @param [Number] buffer: distance in meters to grow/shrink the shapes
    #   checked against (negative allowed)
    # @return [NamedFunction] Shapes whose `buffer` is within `distance` of
    #   geographic_item
    def st_buffer_st_within_sql(geographic_item_id, distance, buffer = 0)
      # You can't always switch the buffer to the second argument, even when
      # distance is 0, without further assumptions (think of buffer being
      # large negative compared to geographic_item_id, but not another shape))
      st_dwithin_sql(
        st_buffer_sql(
          arel_table[:geography],
          buffer
        ),
        select_geography_sql(geographic_item_id),
        distance
      )
    end

    # @param [String] wkt
    # @param [Integer] distance (meters)
    # @return [NamedFunction] Shapes whose distance to wkt is less than
    #   `distance`
    # !! This is computed in 2d
    def intersecting_radius_of_wkt_sql(wkt, distance)
      wkt = quote_string(wkt)
      st_dwithin_sql(
        st_geography_from_text_sql(wkt),
        arel_table[:geography],
        distance
      )
    end

    # @param [String] wkt
    # @param [Integer] distance (meters)
    # @return [NamedFunction] Those items covered by the `distance`-buffer of
    #   wkt
    def within_radius_of_wkt_sql(wkt, distance)
      wkt = quote_string(wkt)

      if buffer_crosses_anti_meridian?(wkt, distance)
        subset_of_shifted_anti_meridian_shape_sql(
          # st_buffer_sql always has longitudes in (-180, 180) in this case, so
          # shift to (0, 360)
          st_shift_longitude_sql(
            st_buffer_sql(
              st_geography_from_text_sql(wkt),
              distance
            )
          )
        )
      else
        subset_of_sql(
          st_buffer_sql(
            st_geography_from_text_sql(wkt),
            distance
          )
        )
      end
    end

    # @param [Integer, Array of Integer] geographic_item_ids
    # @return [Arel::Nodes::SqlLiteral] returns one or more geographic items
    #   combined as a single geometry in a column 'single_geometry'
    def single_geometry_sql(*geographic_item_ids)
      geographic_item_ids.flatten!

      Arel.sql(
        "SELECT ST_Collect(f.the_geom) AS single_geometry
          FROM (
            SELECT (
              ST_DUMP(geography::geometry)
            ).geom AS the_geom
            FROM geographic_items
            WHERE id in (#{geographic_item_ids.join(',')})
          ) AS f"
      )
    end

    # @param [Integer, Array of Integer] geographic_item_ids
    # @return [SelectManager for one id, Arel::Nodes::SqlLiteral for more than
    #   one]
    def items_as_one_geometry_sql(*geographic_item_ids)
      geographic_item_ids.flatten! # *ALWAYS* reduce the pile to a single level of ids
      if geographic_item_ids.count == 1
        select_geometry_sql(geographic_item_ids.first)
      else
        single_geometry_sql(geographic_item_ids)
      end
    end

    # @params [String] wkt
    # @return [NamedFunction] SQL fragment limiting geographic items to those
    # covered by this WKT
    def covered_by_wkt_sql(wkt)
      wkt = quote_string(wkt)
      if crosses_anti_meridian?(wkt)
        translate_longitudes = wkt_needs_longitude_translation(wkt)
        wkt_geom = st_geom_from_text_sql(wkt)
        subset_of_shifted_anti_meridian_shape_sql(
          translate_longitudes ? st_shift_longitude_sql(wkt_geom) : wkt_geom
        )
      else
        subset_of_sql(
          st_geom_from_text_sql(wkt)
        )
      end
    end

    #
    # Scopes
    #

    # return [Scope]
    #   A scope that limits the result to those GeographicItems that have a collecting event
    #   through either the geographic_item or the error_geographic_item
    #
    # A raw SQL join approach for comparison
    #
    # GeographicItem.joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    #   joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    #   where("(g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)").uniq

    # @return [Scope] GeographicItem
    # This uses an Arel table approach, this is ultimately more decomposable if we need. Of use:
    #  http://danshultz.github.io/talks/mastering_activerecord_arel  <- best
    #  https://github.com/rails/arel
    #  http://stackoverflow.com/questions/4500629/use-arel-for-a-nested-set-join-query-and-convert-to-activerecordrelation
    #  http://rdoc.info/github/rails/arel/Arel/SelectManager
    #  http://stackoverflow.com/questions/7976358/activerecord-arel-or-condition
    #
    def with_collecting_event_through_georeferences
      geographic_items = GeographicItem.arel_table
      georeferences = Georeference.arel_table
      g1 = georeferences.alias('a')
      g2 = georeferences.alias('b')

      c = geographic_items.join(g1, Arel::Nodes::OuterJoin).on(geographic_items[:id].eq(g1[:geographic_item_id]))
        .join(g2, Arel::Nodes::OuterJoin).on(geographic_items[:id].eq(g2[:error_geographic_item_id]))

      GeographicItem.joins(# turn the Arel back into scope
                            c.join_sources # translate the Arel join to a join hash(?)
                          ).where(
                            g1[:id].not_eq(nil).or(g2[:id].not_eq(nil)) # returns a Arel::Nodes::Grouping
                          ).distinct
    end

    # This is a geographic intersect, not geometric
    def intersecting(shape, *geographic_item_ids)
      shape = shape.to_s.downcase
      if shape == 'any'
        pieces = []
        SHAPE_TYPES.each { |shape|
          pieces.push(
            intersecting(shape, geographic_item_ids).to_a
          )
        }

        # @TODO change 'id in (?)' to some other sql construct
        GeographicItem.where(id: pieces.flatten.map(&:id))
      else
        a = geographic_item_ids.flatten.collect { |geographic_item_id|
          # seems like we want this: http://danshultz.github.io/talks/mastering_activerecord_arel/#/15/2
          st_intersects_sql(
            shape_column_sql(shape),
            select_geography_sql(geographic_item_id)
          )
        }

        q = a.first
        if a.count > 1
          a[1..].each { |i| q = q.or(i) }
        end

        where(q)
      end
    end

    # rubocop:disable Metrics/MethodLength
    # @param [String] shape can be any of SHAPE_TYPES, or 'any' to check
    # against all types, 'any_poly' to check against 'polygon' or
    # 'multi_polygon', or 'any_line' to check against 'line_string' or
    # 'multi_line_string'.
    # @param [GeographicItem] geographic_items or array of geographic_items
    #                         to be tested.
    # @return [Scope] of GeographicItems whose `shape` contains at least one
    #                 of geographic_items.
    #                 !! Returns geographic_item when geographic_item is of
    #                    type `shape`
    #
    # If this scope is given an Array of GeographicItems as a second parameter,
    # it will return the 'OR' of each of the objects against the table.
    # SELECT COUNT(*) FROM "geographic_items"
    #        WHERE (ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)'))
    #               OR ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)')))
    #
    def st_covers(shape, *geographic_items)
      geographic_items.flatten! # in case there is a array of arrays, or multiple objects
      shape = shape.to_s.downcase
      case shape
      when 'any'
        part = []
        SHAPE_TYPES.each { |s|
          part.push(st_covers(s, geographic_items).to_a)
        }
        # TODO: change 'id in (?)' to some other sql construct
        where(id: part.flatten.map(&:id))

      when 'any_poly', 'any_line'
        part = []
        SHAPE_TYPES.each { |s|
          s = s.to_s
          if s.index(shape.gsub('any_', ''))
            part.push(st_covers(s, geographic_items).to_a)
          end
        }
        # TODO: change 'id in (?)' to some other sql construct
        where(id: part.flatten.map(&:id))

      else
        a = geographic_items.flatten.collect { |geographic_item|
          st_covers_sql(
            shape_column_sql(shape),
            select_geometry_sql(geographic_item.id)
          )
        }

        q = a.first
        if a.count > 1
          a[1..].each { |i| q = q.or(i) }
        end

        # This will prevent the invocation of *ALL* of the GeographicItems
        # if there are no GeographicItems in the request (see
        # CollectingEvent.name_hash(types)).
        q = 'FALSE' if q.blank?
        where(q) # .not_including(geographic_items)
      end
    end
    # rubocop:enable Metrics/MethodLength

    # rubocop:disable Metrics/MethodLength
    # @param shape [String] can be any of SHAPE_TYPES, or 'any' to check
    # against all types, 'any_poly' to check against 'polygon' or
    # 'multi_polygon', or 'any_line' to check against 'line_string' or
    # 'multi_line_string'.
    # @param geographic_items [GeographicItem] Can be a single
    # GeographicItem, or an array of GeographicItem.
    # @return [Scope] of all GeographicItems of the given `shape` covered by
    # one or more of geographic_items
    # !! Returns geographic_item when geographic_item is of type `shape`
    def st_covered_by(shape, *geographic_items)
      shape = shape.to_s.downcase
      case shape
      when 'any'
        part = []
        SHAPE_TYPES.each { |s|
          part.push(GeographicItem.st_covered_by(s, geographic_items).to_a)
        }
        # @TODO change 'id in (?)' to some other sql construct
        GeographicItem.where(id: part.flatten.map(&:id))

      when 'any_poly', 'any_line'
        part = []
        SHAPE_TYPES.each { |s|
          s = s.to_s
          if s.index(shape.gsub('any_', ''))
            part.push(GeographicItem.st_covered_by(s, geographic_items).to_a)
          end
        }
        # @TODO change 'id in (?)' to some other sql construct
        GeographicItem.where(id: part.flatten.map(&:id))

      else
        a = geographic_items.flatten.collect { |geographic_item|
          st_covered_by_sql(
            shape_column_sql(shape),
            select_geometry_sql(geographic_item.id)
          )
        }

        q = a.first
        if a.count > 1
          a[1..].each { |i| q = q.or(i) }
        end

        q = 'FALSE' if q.blank?
        where(q) # .not_including(geographic_items)
      end
    end
    # rubocop:enable Metrics/MethodLength

    # @param [RGeo::Point] center in lon/lat
    # @param [Integer] radius of the circle, in meters
    # @param [Integer] buffer_resolution: the number of sides of the polygon
    #                  approximation per quarter circle
    # @return [RGeo::Polygon] A polygon approximation of the desired circle, in
    #                         geographic coordinates
    def circle(center, radius, buffer_resolution = 8)
      circle_wkb = select_value(
        st_buffer_sql(
          st_geography_from_text_sql(
            "POINT (#{center.lon} #{center.lat})",
          ),
          radius,
          num_seg_quarter_circle: buffer_resolution
        )
      )

      make_valid_non_anti_meridian_crossing_shape(
        Gis::FACTORY.parse_wkb(circle_wkb).as_text
      )
    end

    #
    # Other
    #

    # @param [RGeo::Point] point
    # @return [Hash]
    #   as per #inferred_geographic_name_hierarchy but for Rgeo point
    def point_inferred_geographic_name_hierarchy(point)
      where(superset_of_sql(st_geom_from_text_sql(point.to_s)))
      .order(cached_total_area: :ASC)
      .first&.inferred_geographic_name_hierarchy
    end

    def geography_as_geometry
      Arel.sql('geography::geometry')
    end

    # @param [GeographicItem]
    # @return [Scope]
    def not_including(geographic_items)
      where.not(id: geographic_items)
    end

  end # class << self

  # @return [Hash]
  #    a geographic_name_classification or empty Hash
  # This is a quick approach that works only when
  # the geographic_item is linked explicitly to a GeographicArea.
  #
  # !! Note that it is not impossible for a GeographicItem to be linked
  # to > 1 GeographicArea, in that case we are assuming that all are
  # equally refined, this might not be the case in the future because
  # of how the GeographicArea gazetteer is indexed.
  def quick_geographic_name_hierarchy
    geographic_areas.order(:id).each do |ga|
      h = ga.geographic_name_classification # not quick enough !!
      return h if h.present?
    end

    {}
  end

  # @return [Hash]
  #   a geographic_name_classification (see GeographicArea) inferred by
  #   finding the smallest area covering this GeographicItem, in the most
  #   accurate gazetteer and using it to return country/state/county. See also
  #   the logic in filling in missing levels in GeographicArea.
  def inferred_geographic_name_hierarchy
    if small_area = covering_geographic_areas
      .joins(:geographic_areas_geographic_items)
      .merge(GeographicAreasGeographicItem.ordered_by_data_origin)
      .ordered_by_area
      .first

      return small_area.geographic_name_classification
    end

    {}
  end

  def geographic_name_hierarchy
    a = quick_geographic_name_hierarchy # quick; almost never the case, UI not setup to do this
    return a if a.present?
    inferred_geographic_name_hierarchy # slow
  end

  # @return [Scope]
  #   the Geographic Areas that cover (gis) this geographic item
  def covering_geographic_areas
    GeographicArea
      .joins(:geographic_items)
      .includes(:geographic_area_type)
      .joins(
        "JOIN (#{GeographicItem.superset_of_union_of(id).to_sql}) AS j ON " \
        'geographic_items.id = j.id'
      )
  end

  # @param [GeographicItem] geographic_item
  # @return [Double] distance in meters
  # Works with changed and non persisted objects
  def st_distance_to_geographic_item(geographic_item)
    if persisted? && !changed?
      a = self.class.select_geography_sql(id)
    else
      a = self.class.st_geography_from_text_sql(geo_object.to_s)
    end

    if geographic_item.persisted? && !geographic_item.changed?
      b = self.class.select_geography_sql(geographic_item.id)
    else
      b = self.class.st_geography_from_text_sql(geographic_item.geo_object.to_s)
    end

    self.class.select_value(
      self.class.st_distance_sql(a, b)
    )
  end

  # @return [String]
  #   a WKT POINT representing the geometry centroid of the geographic item
  def st_centroid
    select_from_self(
      self.class.st_as_text_sql(
        self.class.st_centroid_sql(self.class.geography_as_geometry)
      )
    )['st_astext']
  end

  # @return [RGeo::Geographic::ProjectedPointImpl]
  #    representing the geometric centroid of this geographic item
  def centroid
    return geo_object if geo_object_type == :point

    Gis::FACTORY.parse_wkt(st_centroid)
  end

  # @return [Array]
  #   the lat, long, as STRINGs for the geometric centroid of this geographic
  #   item
  #   Meh- this: https://postgis.net/docs/en/ST_MinimumBoundingRadius.html
  def center_coords
    select_from_self(
      self.class.lat_long_sql(:latitude),
      self.class.lat_long_sql(:longitude)
    ).values
  end

  # !!TODO: migrate these to use native column calls

  # @param [geo_object]
  # @return [Boolean]
  def contains?(target_geo_object)
    return nil if target_geo_object.nil?
    self.geo_object.contains?(target_geo_object)
  end

  # @param [geo_object]
  # @return [Boolean]
  def within?(target_geo_object)
    self.geo_object.within?(target_geo_object)
  end

  # @param [geo_object]
  # @return [Boolean]
  def intersects?(target_geo_object)
    self.geo_object.intersects?(target_geo_object)
  end

  # @return [Hash] in GeoJSON format
  def to_geo_json
    JSON.parse(
      select_from_self(
        self.class.st_as_geo_json_sql(self.class.arel_table[:geography])
      )['st_asgeojson']
    )
  end

  # @return [Hash]
  #   the shape as a GeoJSON Feature with some item metadata
  def to_geo_json_feature
    {
      'type' => 'Feature',
      'geometry' => to_geo_json,
      'properties' => {
        'geographic_item' => {
          'id' => id
        }
      }
    }
  end

  # @param value [String] geojson like:
  #   '{"type":"Feature","geometry":{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}'
  #
  #   '{"type":"Feature","geometry":{"type":"Polygon","coordinates":"[[[-125.29394388198853, 48.584480409793],
  #      [-67.11035013198853, 45.09937589848195],[-80.64550638198853, 25.01924647619111],[-117.55956888198853,
  #      32.5591595028449],[-125.29394388198853, 48.584480409793]]]"},"properties":{}}'
  #
  #  '{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}'
  #
  def shape=(value)
    return if value.blank?

    begin
      geom = RGeo::GeoJSON.decode(value, json_parser: :json, geo_factory: Gis::FACTORY)
    rescue RGeo::Error::InvalidGeometry => e
      errors.add(:base, "invalid geometry: #{e}")
      return
    end

    object = nil

    s = geom.respond_to?(:geometry) ? geom.geometry.to_s : geom.to_s

    begin
      object = Gis::FACTORY.parse_wkt(s)
    rescue RGeo::Error::InvalidGeometry => e
      errors.add(:self, "Shape value is an Invalid Geometry: '#{e}'")
      return
    end

    write_attribute(:geography, object)
  end

  # @return [String] wkt
  def to_wkt
    select_from_self(
      self.class.st_as_text_sql(self.class.geography_as_geometry)
    )['st_astext']
  end

  # @return [Float] area in square meters, calculated
  # TODO: share with world
  def area
    select_from_self(
      self.class.st_area_sql(self.class.arel_table[:geography])
    )['st_area']
  end

  # TODO: This is bad, while internal use of ONE_WEST_MEAN is consistent it is in-accurate given the vast differences of radius vs. lat/long position.
  # When we strike the error-polygon from radius we should remove this
  #
  # Use case is returning the radius from a circle we calculated via buffer for error-polygon creation.
  def radius
    r = select_from_self(
      self.class.st_minimum_bounding_radius_sql(
        self.class.geography_as_geometry
      )
    )['st_minimumboundingradius'].split(',').last.chop.to_f

    (r * Utilities::Geo::ONE_WEST_MEAN).to_i
  end

  # Convention is to store in PostGIS in CCW
  # @return Array [Boolean]
  #   false - cw
  #   true - ccw (preferred), except see donuts
  def orientations
    if geography_is_multi_polygon?
      ApplicationRecord.connection.execute(
            "SELECT ST_IsPolygonCCW(a.geom) as is_ccw
              FROM ( SELECT b.id, (ST_Dump(p_geom)).geom AS geom
                  FROM (SELECT id, geography::geometry AS p_geom FROM geographic_items where id = #{id}) AS b
            ) AS a;").collect{|a| a['is_ccw']}
    elsif geography_is_polygon?
      ApplicationRecord.connection.execute(
        "SELECT ST_IsPolygonCCW(geography::geometry) as is_ccw \
        FROM geographic_items where  id = #{id};"
      ).collect { |a| a['is_ccw'] }
    else
      []
    end
  end

  # @return Boolean
  #   looks at all orientations
  #   if they follow the pattern [true, false, ... <all false>] then `true`, else `false`
  # !! Does not confirm that shapes are nested !!
  def is_basic_donut?
    a = orientations
    b = a.shift
    return false unless b

    a.uniq! == [false]
  end

  def st_is_valid
    select_from_self(
      self.class.st_is_valid_sql(
        self.class.geography_as_geometry
      )
    )['st_isvalid']
  end

  def st_is_valid_reason
    select_from_self(
      self.class.st_is_valid_reason_sql(
        self.class.geography_as_geometry
      )
    )['st_isvalidreason']
  end

  # @return [Symbol]
  #   the specific type of geography: :point, :multi_polygon, etc.
  def geo_object_type
    return geography.geometry_type.type_name.underscore.to_sym if geography

    nil
  end

  # @return [RGeo instance, nil]
  #  the Rgeo shape
  def geo_object
    geography
  end

  private

  # @param [String or Symbol] shape, the kind of shape you want, e.g. :polygon
  # @return [Arel::Nodes::Case]
  #   A Case statement that selects the geography column if that column is of
  #   type `shape`, otherwise NULL
  def self.shape_column_sql(shape)
    st_shape = 'ST_' + shape.to_s.camelize

    Arel::Nodes::Case.new(st_geometry_type(arel_table[:geography]))
      .when(st_shape.to_sym).then(arel_table[:geography])
      .else(Arel.sql('NULL'))
  end

  def self.shape_is_type(shape)
    st_shape = 'ST_' + shape.to_s.camelize

    Arel::Nodes::Case.new(st_geometry_type(arel_table[:geography]))
      .when(st_shape.to_sym).then(Arel.sql('TRUE'))
      .else(Arel.sql('FALSE'))
  end

  def geography_is_point?
    geo_object_type == :point
  end

  def geography_is_polygon?
    geo_object_type == :polygon
  end

  def geography_is_multi_polygon?
    geo_object_type == :multi_polygon
  end

  def select_from_self(*named_function)
    # This is faster than GeographicItem.select(...)
    ActiveRecord::Base.connection.execute(
      self.class.arel_table
        .project(*named_function)
        .where(self.class.arel_table[:id].eq(id))
        .to_sql
    ).to_a.first
  end

  def self.select_value(named_function)
    # This is faster than select(...)
    ActiveRecord::Base.connection.select_value(
      'SELECT ' +
      named_function.to_sql
    )
  end

  def align_winding
    if orientations.flatten.include?(false)
      if (geography_is_polygon? || geography_is_multi_polygon?)
        ApplicationRecord.connection.execute(
          "UPDATE geographic_items SET geography = ST_ForcePolygonCCW(geography::geometry)
            WHERE id = #{self.id};"
          )
      end
    end
  end

  # Crude debuging helper, write the shapes
  # to a png
  def self.debug_draw(geographic_item_ids = [])
    return false if geographic_item_ids.empty?
    sql = "SELECT ST_AsPNG(
        ST_AsRaster(
          (SELECT ST_Union(geography::geometry) from geographic_items where id IN (" + geographic_item_ids.join(',') + ")), 1920, 1080
      )
      ) png;"

    # ST_Buffer( multi_polygon::geometry, 0, 'join=bevel'),
    #     1920,
    #     1080)


    result = ActiveRecord::Base.connection.execute(sql).first['png']
    r = ActiveRecord::Base.connection.unescape_bytea(result)

    prefix = if geographic_item_ids.size > 10
               'multiple'
              else
                geographic_item_ids.join('_')
              end

    n = prefix + '_debug.draw.png'

    # Open the file in binary write mode ("wb")
    File.open(n, 'wb') do |file|
      # Write the binary data to the file
      file.write(r)
    end
  end

  # def png

  #   if ids = Otu.joins(:cached_map_items).first.cached_map_items.pluck(:geographic_item_id)

  #     sql = "SELECT ST_AsPNG(
  #    ST_AsRaster(
  #        ST_Buffer( multi_polygon::geometry, 0, 'join=bevel'),
  #            1024,
  #            768)
  #     ) png
  #        from geographic_items where id IN (" + ids.join(',') + ');'

  #     # hack, not the best way to unpack result
  #     result = ActiveRecord::Base.connection.execute(sql).first['png']
  #     r = ActiveRecord::Base.connection.unescape_bytea(result)

  #     send_data r, filename: 'foo.png', type: 'imnage/png'

  #   else
  #     render json: {foo: false}
  #   end
  # end

  def set_cached
    update_column(:cached_total_area, area)
  end

  def some_data_is_provided
    errors.add(:base, 'No shape provided or provided shape is invalid') if
      geography.nil?
  end

  def normalize_point_longitude
    return if !geography_is_point?

    if geography.x < -180.0 || geography.x > 180.0
      new_lon = geography.x % 360.0
      new_lon = new_lon - 360.0 if new_lon > 180.0
      self.geography = Gis::FACTORY.point(new_lon, geography.y)
    end
  end

  # @return [Boolean] true if wkt needs to be longitude-translated to make its
  # longitudes be in the interval (0, 360).
  # This is a bit of a hack: whether or not to shift wkt depends on whether or
  # not its longitudes are already in the range (0,360), which indicates to rgeo
  # that hemisphere-crossing lines cross the anti-meridian, not the meridian.
  # (We make the assumption that there aren't coordinates in both (180, 360) and
  # (-180, 0), which is actually possible with hand-entered wkt, and not easy
  # to deal with.)
  # TODO: find a more canonical/rgeo way to do this?
  # TODO: support other wkt shape types as needed. MultiPolygon covers all
  # polygon inputs from leaflet, the main case.
  def self.wkt_needs_longitude_translation(wkt)
    # Use a cartesian factory that doesn't automagically normalize its
    # longitude inputs, as Gis::FACTORY does.
    s = RGeo::Cartesian.simple_factory.parse_wkt(wkt)

    translate_longitudes = true
    # Currently this is intended to support Leaflet polygons.
    if (s.geometry_type.type_name == 'MultiPolygon' && s.count == 1)
      translate_longitudes =
        s[0].exterior_ring.points.map(&:x).any? { |l| l < 0 }
    end

    translate_longitudes
  end

  def self.geography_cast(sql)
    # Arel::Nodes::NamedFunction.new('CAST', [sql.as('geography')])
    Arel.sql(
      Arel::Nodes::Grouping.new(sql).to_sql + '::geography'
    )
  end

  def self.geometry_cast(sql)
    # specs fail using:
    # Arel::Nodes::NamedFunction.new('CAST', [sql.as('geometry')])
    Arel.sql(
      Arel::Nodes::Grouping.new(sql).to_sql + '::geometry'
    )
  end

  def self.quote_string(s)
    ActiveRecord::Base.connection.quote_string(s)
  end

  # From the docs: Splits string at occurrences of delimiter and returns the
  # n'th field (counting from one), or when n is negative, returns the
  # |n|'th-from-last field.
  # !! postgresql-specific
  def self.split_part(s, delimiter, n)
    Arel::Nodes::NamedFunction.new(
        'split_part',
        [
          Arel::Nodes.build_quoted(s),
          Arel::Nodes.build_quoted(delimiter),
          Arel::Nodes.build_quoted(n)
        ]
      )
  end

end

#geographyRGeo::Geographic::Geography

Holds a shape of any geographic type.

Returns:

  • (RGeo::Geographic::Geography)


18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
# File 'app/models/geographic_item.rb', line 18

class GeographicItem < ApplicationRecord
  include Housekeeping::Users
  include Housekeeping::Timestamps
  include Shared::HasPapertrail
  include Shared::IsData
  include Shared::SharedAcrossProjects

  # @return [Boolean, RGeo object]
  # @params value [Hash in GeoJSON format] ?!
  # TODO: WHY! boolean not nil, or object
  # Used to build geographic items from a shape [ of what class ] !?
  attr_accessor :shape

  # @return [Boolean]
  #   When true cached values are not built
  attr_accessor :no_cached

  SHAPE_TYPES = [
    :point,
    :line_string,
    :polygon,
    :multi_point,
    :multi_line_string,
    :multi_polygon,
    :geometry_collection
  ].freeze

  # ANTI_MERIDIAN = '0X0102000020E61000000200000000000000008066400000000000405640000000000080664000000000004056C0'
  ANTI_MERIDIAN = 'LINESTRING (180 89.0, 180 -89.0)'.freeze

  # TODO Remove once `type` for GI STI has been deleted (it's currently there to
  # ease switching branches during development).
  self.inheritance_column = nil

  has_many :cached_map_items, inverse_of: :geographic_item

  has_many :geographic_areas_geographic_items, dependent: :destroy, inverse_of: :geographic_item
  has_many :geographic_areas, through: :geographic_areas_geographic_items
  has_many :geographic_area_types, through: :geographic_areas
  has_many :parent_geographic_areas, through: :geographic_areas, source: :parent

  has_many :georeferences, inverse_of: :geographic_item
  has_many :georeferences_through_error_geographic_item, class_name: 'Georeference', foreign_key: :error_geographic_item_id, inverse_of: :error_geographic_item
  has_many :collecting_events_through_georeferences, through: :georeferences, source: :collecting_event
  has_many :collecting_events_through_georeference_error_geographic_item,
    through: :georeferences_through_error_geographic_item, source: :collecting_event

  has_many :gazetteers, inverse_of: :geographic_item

  validate :some_data_is_provided

  scope :include_collecting_event, -> { includes(:collecting_events_through_georeferences) }
  scope :geo_with_collecting_event, -> { joins(:collecting_events_through_georeferences) }
  scope :err_with_collecting_event, -> { joins(:georeferences_through_error_geographic_item) }

  scope :points, -> { where(shape_is_type(:point)) }
  scope :multi_points, -> { where(shape_is_type(:multi_point)) }
  scope :line_strings, -> { where(shape_is_type(:line_string)) }
  scope :multi_line_strings, -> { where(shape_is_type(:multi_line_string)) }
  scope :polygons, -> { where(shape_is_type(:polygon)) }
  scope :multi_polygons, -> { where(shape_is_type(:multi_polygon)) }
  scope :geometry_collections, -> { where(shape_is_type(:geometry_collection)) }

  # Retrieving a geography point requires instantiating that point using our
  # Gis::FACTORY, which itself normalizes longitudes, so this is really just
  # ensuring that the normalized longitude is what we'll see stored in the
  # database as well.
  # TODO if/when needed: multipoints and points/multipoints inside
  # geometry_collections also need normalizing (Gis::FACTORY normalizes
  # longitudes of all other shapes).
  before_save :normalize_point_longitude

  after_save :set_cached, unless: Proc.new {|n| n.no_cached || errors.any? }
  after_save :align_winding

  class << self

    # DEPRECATED, moved to ::Queries::GeographicItem
    def st_union(geographic_item_scope)
      select('ST_Union(geography::geometry) as st_union')
        .where(id: geographic_item_scope.pluck(:id))
    end

    def st_covers_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Covers',
        [
          geometry_cast(shape1),
          geometry_cast(shape2)
        ]
      )
    end

    # True for those shapes that cover shape.
    def superset_of_sql(shape)
      st_covers_sql(
        geography_as_geometry,
        shape
      )
    end

    # @return [Scope] of items covering the union of geographic_item_ids;
    # does not include any of geographic_item_ids
    def superset_of_union_of(*geographic_item_ids)
      where(
        superset_of_sql(
          items_as_one_geometry_sql(*geographic_item_ids)
        )
      )
      .not_ids(*geographic_item_ids)
    end

    def st_covered_by_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_CoveredBy',
        [
          geometry_cast(shape1),
          geometry_cast(shape2)
        ]
      )
    end

    # True for those shapes that are subsets of shape.
    def subset_of_sql(shape)
      st_covered_by_sql(
        geography_as_geometry,
        shape
      )
    end

    # Only called when shape crosses the anti-meridian
    # !! shape must be pre-shifted to longitude-range (0, 360) !!
    def subset_of_shifted_anti_meridian_shape_sql(shape)
      st_covered_by_sql(
        # All database geographic_items are (!! should be !!) stored in our
        # Gis::FACTORY-enforced longitude range (-180, 180), so always need to
        # be shifted in this case to the range (0, 360).
        st_shift_longitude_sql(geography_as_geometry),
        shape
      )
    end

    # Note: !! If the target GeographicItem#id crosses the anti-meridian then
    # you may/will get unexpected results.
    def subset_of_union_of_sql(*geographic_item_ids)
      subset_of_sql(
        items_as_one_geometry_sql(*geographic_item_ids)
      )
    end

    def st_distance_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Distance', [
          shape1,
          shape2
        ]
      )
    end

    def st_area_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Area', [
          shape
        ]
      )
    end

    # Intended here to be used as an aggregate function
    def st_union_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Union', [
          shape
        ]
      )
    end

    def st_dump_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Dump', [
          shape
        ]
      )
    end

    def st_collect_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Collect', [
          shape
        ]
      )
    end

    def st_is_valid_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_IsValid', [
          shape
        ]
      )
    end

    def st_is_valid_reason_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_IsValidReason', [
          shape
        ]
      )
    end

    def st_as_text_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_AsText', [
          shape
        ]
      )
    end

    def st_geometry_type(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_GeometryType', [
          geometry_cast(shape)
        ]
      )
    end

    def st_minimum_bounding_radius_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_MinimumBoundingRadius', [
          shape
        ]
      )
    end

    def st_as_geo_json_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_AsGeoJSON', [
          shape
        ]
      )
    end

    def st_geography_from_text_sql(wkt)
      wkt = quote_string(wkt)
      Arel::Nodes::NamedFunction.new(
        'ST_GeographyFromText', [
          Arel::Nodes.build_quoted(wkt),
        ]
      )
    end

    alias st_geog_from_text_sql st_geography_from_text_sql

    def st_geometry_from_text_sql(wkt)
      wkt = quote_string(wkt)
      Arel::Nodes::NamedFunction.new(
        'ST_GeometryFromText', [
          Arel::Nodes.build_quoted(wkt),
          Arel::Nodes.build_quoted(4326)
        ]
      )
    end

    alias st_geom_from_text_sql st_geometry_from_text_sql

    def st_centroid_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_Centroid', [
          shape
        ]
      )
    end

    def st_buffer_sql(shape, distance, num_seg_quarter_circle: 8)
      Arel::Nodes::NamedFunction.new(
        'ST_Buffer', [
          geography_cast(shape),
          Arel::Nodes.build_quoted(distance),
          Arel::Nodes.build_quoted(num_seg_quarter_circle)
        ]
      )
    end

    # # !! Keep in mind that you may get different results depending on if the
    # inputs are geographies or geometries.
    def st_intersects_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Intersects', [
          shape1,
          shape2
        ]
      )
    end

    # !! Keep in mind that you may get different results depending on if the
    # inputs are geographies or geometries.
    def st_intersection_sql(shape1, shape2)
      Arel::Nodes::NamedFunction.new(
        'ST_Intersection', [
          shape1,
          shape2
        ]
      )
    end

    def st_shift_longitude_sql(shape)
      Arel::Nodes::NamedFunction.new(
        'ST_ShiftLongitude', [
          geometry_cast(shape)
        ]
      )
    end

    # True if the distance from shape1 to shape2 is less than `distance`. This
    # is a geography dwithin, distance is in meters.
    def st_dwithin_sql(shape1, shape2, distance)
      Arel::Nodes::NamedFunction.new(
        'ST_DWithin', [
          geography_cast(shape1),
          geography_cast(shape2),
          Arel::Nodes.build_quoted(distance)
        ]
      )
    end

    def st_as_lat_lon_text_sql(point_shape, format = '')
      Arel::Nodes::NamedFunction.new(
        'ST_AsLatLonText', [
          geometry_cast(point_shape),
          Arel::Nodes.build_quoted(format)
        ]
      )
    end

    # Returns valid shapes unchanged.
    # !! This will give the wrong result on anti-meridian-crossing shapes stored
    # in Gis::FACTORY coordinates, use anti_meridian_crossing_make_valid
    # instead in that case.
    def st_make_valid_sql(shape)
      # TODO add params once we're on GEOS >= 3.10, they're not used until then
      #params = "method=structure keepcollapsed=false"
      Arel::Nodes::NamedFunction.new(
        'ST_MakeValid', [
          geometry_cast(shape)
          #Arel::Nodes.build_quoted(params)
        ]
      )
    end

    # Assumes wkt crosses the anti-meridian.
    # !! Note you must apply St_ShiftLongitude to the return value to have the
    # correct interpretation of the return value's relation to the
    # anti-meridian (cf. anti_meridian_spec.rb).
    def anti_meridian_crossing_make_valid_sql(wkt)
      st_make_valid_sql(
        st_shift_longitude_sql(
          st_geom_from_text_sql(
            wkt
          )
        )
      )
    end

    def make_valid_non_anti_meridian_crossing_shape(wkt)
      if crosses_anti_meridian?(wkt)
        split_along_anti_meridian(wkt, make_valid: true)
      else
        wkb = select_value(
          st_make_valid_sql(
            st_geom_from_text_sql(
              wkt
            )
          )
        )

        ::Gis::FACTORY.parse_wkb(wkb)
      end
    end

    # @param [String] wkt
    # @return RGeo shape for wkt expressed as a union of pieces none of which
    # intersect the anti-meridian. Slightly lossy (has to be), and may turn
    # polygon into multi-polygon, etc.
    # Assumes wkt intersects the anti-meridian.
    def split_along_anti_meridian(wkt, make_valid: false)
      wkt = quote_string(wkt)
      # Intended to be the exterior of a tiny buffer around the anti-meridian,
      # expressed as two sheets/near-hemispheres that meet at long=0=360.
      anti_meridian_exterior = 'MULTIPOLYGON(
        ((0 -89.999999, 179.999999 -89.999999, 179.999999 89.999999, 0 89.999999, 0 -89.999999)),
        ((180.000001 -89.999999, 360 -89.999999, 360 89.999999, 180.000001 89.999999, 180.000001 -89.999999))
      )'

      s = make_valid ?
        anti_meridian_crossing_make_valid_sql(wkt) :
        st_shift_longitude_sql(st_geom_from_text_sql(wkt))

      wkb = select_value(
        st_intersection_sql(
          s,
          st_geom_from_text_sql(anti_meridian_exterior)
        )
      )

      ::Gis::FACTORY.parse_wkb(wkb)
    end

    # @param [String] wkt
    # @return [Boolean]
    #   whether or not the wkt intersects with the anti-meridian
    def crosses_anti_meridian?(wkt)
      wkt = quote_string(wkt)
      select_value(
        st_intersects_sql(
          st_geography_from_text_sql(wkt),
          st_geography_from_text_sql(ANTI_MERIDIAN)
        )
      )
    end

    # @param [String] wkt
    # @param [Integer] buffer distance
    # @return [Boolean]
    #   whether or not the radius-buffer of wkt-point intersects the
    #   anti-meridian
    def buffer_crosses_anti_meridian?(wkt, distance)
      wkt = quote_string(wkt)
      select_value(
        st_intersects_sql(
          st_buffer_sql(
            st_geography_from_text_sql(wkt),
            distance
          ),
          st_geography_from_text_sql(ANTI_MERIDIAN)
        )
      )
    end

    # Unused, kept for reference
    # @param [Integer] ids
    # @return [Boolean]
    #   whether or not any GeographicItem passed intersects the anti-meridian
    #   !! StrongParams security considerations This is our first line of
    #   defense against queries that define multiple shapes, one or more of
    #   which crosses the anti-meridian.  In this case the current TW strategy
    #   within the UI is to abandon the search, and prompt the user to
    #   refactor the query.
    def crosses_anti_meridian_by_id?(*ids)
      q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \
            'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN
      GeographicItem.find_by_sql(q).first.r
    end

    #
    # SQL fragments
    #

    # @param [Integer, String]
    # @return [SelectManager]
    #   a SQL select statement that returns the *geometry* for the
    #   geographic_item with the specified id
    def select_geometry_sql(geographic_item_id)
      arel_table
        .project(geography_as_geometry)
        .where(arel_table[:id].eq(geographic_item_id))
    end

    # @param [Integer, String]
    # @return [SelectManager]
    #   a SQL select statement that returns the geography for the
    #   geographic_item with the specified id
    def select_geography_sql(geographic_item_id)
      arel_table
        .project(arel_table[:geography])
        .where(arel_table[:id].eq(geographic_item_id))
    end

    # @param [Symbol] choice, either :latitude or :longitude
    # @return [Arel::Nodes::NamedFunction]
    #   a fragment returning either latitude or longitude columns
    def lat_long_sql(choice)
      return nil unless [:latitude, :longitude].include?(choice)
      f = 'D.DDDDDD'
      v = (choice == :latitude ? 1 : 2)

      split_part(
        st_as_lat_lon_text_sql(
          st_centroid_sql(geography_as_geometry),
          f
        ),
        ' ',
        v
      ).as(choice.to_s)
    end

    # @param [Integer] geographic_item_id
    # @param [Integer] radius in meters
    # @return [Scope] of shapes within distance of (i.e. whose
    #   distance-buffer intersects) geographic_item_id
    def within_radius_of_item_sql(geographic_item_id, radius)
      st_dwithin_sql(
        select_geography_sql(geographic_item_id),
        arel_table[:geography],
        radius
      )
    end

    def within_radius_of_item(geographic_item_id, radius)
      where(within_radius_of_item_sql(geographic_item_id, radius))
    end

    # @param [Integer] geographic_item_id
    # @param [Number] distance (in meters) (positive only?!)
    # @param [Number] buffer: distance in meters to grow/shrink the shapes
    #   checked against (negative allowed)
    # @return [NamedFunction] Shapes whose `buffer` is within `distance` of
    #   geographic_item
    def st_buffer_st_within_sql(geographic_item_id, distance, buffer = 0)
      # You can't always switch the buffer to the second argument, even when
      # distance is 0, without further assumptions (think of buffer being
      # large negative compared to geographic_item_id, but not another shape))
      st_dwithin_sql(
        st_buffer_sql(
          arel_table[:geography],
          buffer
        ),
        select_geography_sql(geographic_item_id),
        distance
      )
    end

    # @param [String] wkt
    # @param [Integer] distance (meters)
    # @return [NamedFunction] Shapes whose distance to wkt is less than
    #   `distance`
    # !! This is computed in 2d
    def intersecting_radius_of_wkt_sql(wkt, distance)
      wkt = quote_string(wkt)
      st_dwithin_sql(
        st_geography_from_text_sql(wkt),
        arel_table[:geography],
        distance
      )
    end

    # @param [String] wkt
    # @param [Integer] distance (meters)
    # @return [NamedFunction] Those items covered by the `distance`-buffer of
    #   wkt
    def within_radius_of_wkt_sql(wkt, distance)
      wkt = quote_string(wkt)

      if buffer_crosses_anti_meridian?(wkt, distance)
        subset_of_shifted_anti_meridian_shape_sql(
          # st_buffer_sql always has longitudes in (-180, 180) in this case, so
          # shift to (0, 360)
          st_shift_longitude_sql(
            st_buffer_sql(
              st_geography_from_text_sql(wkt),
              distance
            )
          )
        )
      else
        subset_of_sql(
          st_buffer_sql(
            st_geography_from_text_sql(wkt),
            distance
          )
        )
      end
    end

    # @param [Integer, Array of Integer] geographic_item_ids
    # @return [Arel::Nodes::SqlLiteral] returns one or more geographic items
    #   combined as a single geometry in a column 'single_geometry'
    def single_geometry_sql(*geographic_item_ids)
      geographic_item_ids.flatten!

      Arel.sql(
        "SELECT ST_Collect(f.the_geom) AS single_geometry
          FROM (
            SELECT (
              ST_DUMP(geography::geometry)
            ).geom AS the_geom
            FROM geographic_items
            WHERE id in (#{geographic_item_ids.join(',')})
          ) AS f"
      )
    end

    # @param [Integer, Array of Integer] geographic_item_ids
    # @return [SelectManager for one id, Arel::Nodes::SqlLiteral for more than
    #   one]
    def items_as_one_geometry_sql(*geographic_item_ids)
      geographic_item_ids.flatten! # *ALWAYS* reduce the pile to a single level of ids
      if geographic_item_ids.count == 1
        select_geometry_sql(geographic_item_ids.first)
      else
        single_geometry_sql(geographic_item_ids)
      end
    end

    # @params [String] wkt
    # @return [NamedFunction] SQL fragment limiting geographic items to those
    # covered by this WKT
    def covered_by_wkt_sql(wkt)
      wkt = quote_string(wkt)
      if crosses_anti_meridian?(wkt)
        translate_longitudes = wkt_needs_longitude_translation(wkt)
        wkt_geom = st_geom_from_text_sql(wkt)
        subset_of_shifted_anti_meridian_shape_sql(
          translate_longitudes ? st_shift_longitude_sql(wkt_geom) : wkt_geom
        )
      else
        subset_of_sql(
          st_geom_from_text_sql(wkt)
        )
      end
    end

    #
    # Scopes
    #

    # return [Scope]
    #   A scope that limits the result to those GeographicItems that have a collecting event
    #   through either the geographic_item or the error_geographic_item
    #
    # A raw SQL join approach for comparison
    #
    # GeographicItem.joins('LEFT JOIN georeferences g1 ON geographic_items.id = g1.geographic_item_id').
    #   joins('LEFT JOIN georeferences g2 ON geographic_items.id = g2.error_geographic_item_id').
    #   where("(g1.geographic_item_id IS NOT NULL OR g2.error_geographic_item_id IS NOT NULL)").uniq

    # @return [Scope] GeographicItem
    # This uses an Arel table approach, this is ultimately more decomposable if we need. Of use:
    #  http://danshultz.github.io/talks/mastering_activerecord_arel  <- best
    #  https://github.com/rails/arel
    #  http://stackoverflow.com/questions/4500629/use-arel-for-a-nested-set-join-query-and-convert-to-activerecordrelation
    #  http://rdoc.info/github/rails/arel/Arel/SelectManager
    #  http://stackoverflow.com/questions/7976358/activerecord-arel-or-condition
    #
    def with_collecting_event_through_georeferences
      geographic_items = GeographicItem.arel_table
      georeferences = Georeference.arel_table
      g1 = georeferences.alias('a')
      g2 = georeferences.alias('b')

      c = geographic_items.join(g1, Arel::Nodes::OuterJoin).on(geographic_items[:id].eq(g1[:geographic_item_id]))
        .join(g2, Arel::Nodes::OuterJoin).on(geographic_items[:id].eq(g2[:error_geographic_item_id]))

      GeographicItem.joins(# turn the Arel back into scope
                            c.join_sources # translate the Arel join to a join hash(?)
                          ).where(
                            g1[:id].not_eq(nil).or(g2[:id].not_eq(nil)) # returns a Arel::Nodes::Grouping
                          ).distinct
    end

    # This is a geographic intersect, not geometric
    def intersecting(shape, *geographic_item_ids)
      shape = shape.to_s.downcase
      if shape == 'any'
        pieces = []
        SHAPE_TYPES.each { |shape|
          pieces.push(
            intersecting(shape, geographic_item_ids).to_a
          )
        }

        # @TODO change 'id in (?)' to some other sql construct
        GeographicItem.where(id: pieces.flatten.map(&:id))
      else
        a = geographic_item_ids.flatten.collect { |geographic_item_id|
          # seems like we want this: http://danshultz.github.io/talks/mastering_activerecord_arel/#/15/2
          st_intersects_sql(
            shape_column_sql(shape),
            select_geography_sql(geographic_item_id)
          )
        }

        q = a.first
        if a.count > 1
          a[1..].each { |i| q = q.or(i) }
        end

        where(q)
      end
    end

    # rubocop:disable Metrics/MethodLength
    # @param [String] shape can be any of SHAPE_TYPES, or 'any' to check
    # against all types, 'any_poly' to check against 'polygon' or
    # 'multi_polygon', or 'any_line' to check against 'line_string' or
    # 'multi_line_string'.
    # @param [GeographicItem] geographic_items or array of geographic_items
    #                         to be tested.
    # @return [Scope] of GeographicItems whose `shape` contains at least one
    #                 of geographic_items.
    #                 !! Returns geographic_item when geographic_item is of
    #                    type `shape`
    #
    # If this scope is given an Array of GeographicItems as a second parameter,
    # it will return the 'OR' of each of the objects against the table.
    # SELECT COUNT(*) FROM "geographic_items"
    #        WHERE (ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)'))
    #               OR ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)')))
    #
    def st_covers(shape, *geographic_items)
      geographic_items.flatten! # in case there is a array of arrays, or multiple objects
      shape = shape.to_s.downcase
      case shape
      when 'any'
        part = []
        SHAPE_TYPES.each { |s|
          part.push(st_covers(s, geographic_items).to_a)
        }
        # TODO: change 'id in (?)' to some other sql construct
        where(id: part.flatten.map(&:id))

      when 'any_poly', 'any_line'
        part = []
        SHAPE_TYPES.each { |s|
          s = s.to_s
          if s.index(shape.gsub('any_', ''))
            part.push(st_covers(s, geographic_items).to_a)
          end
        }
        # TODO: change 'id in (?)' to some other sql construct
        where(id: part.flatten.map(&:id))

      else
        a = geographic_items.flatten.collect { |geographic_item|
          st_covers_sql(
            shape_column_sql(shape),
            select_geometry_sql(geographic_item.id)
          )
        }

        q = a.first
        if a.count > 1
          a[1..].each { |i| q = q.or(i) }
        end

        # This will prevent the invocation of *ALL* of the GeographicItems
        # if there are no GeographicItems in the request (see
        # CollectingEvent.name_hash(types)).
        q = 'FALSE' if q.blank?
        where(q) # .not_including(geographic_items)
      end
    end
    # rubocop:enable Metrics/MethodLength

    # rubocop:disable Metrics/MethodLength
    # @param shape [String] can be any of SHAPE_TYPES, or 'any' to check
    # against all types, 'any_poly' to check against 'polygon' or
    # 'multi_polygon', or 'any_line' to check against 'line_string' or
    # 'multi_line_string'.
    # @param geographic_items [GeographicItem] Can be a single
    # GeographicItem, or an array of GeographicItem.
    # @return [Scope] of all GeographicItems of the given `shape` covered by
    # one or more of geographic_items
    # !! Returns geographic_item when geographic_item is of type `shape`
    def st_covered_by(shape, *geographic_items)
      shape = shape.to_s.downcase
      case shape
      when 'any'
        part = []
        SHAPE_TYPES.each { |s|
          part.push(GeographicItem.st_covered_by(s, geographic_items).to_a)
        }
        # @TODO change 'id in (?)' to some other sql construct
        GeographicItem.where(id: part.flatten.map(&:id))

      when 'any_poly', 'any_line'
        part = []
        SHAPE_TYPES.each { |s|
          s = s.to_s
          if s.index(shape.gsub('any_', ''))
            part.push(GeographicItem.st_covered_by(s, geographic_items).to_a)
          end
        }
        # @TODO change 'id in (?)' to some other sql construct
        GeographicItem.where(id: part.flatten.map(&:id))

      else
        a = geographic_items.flatten.collect { |geographic_item|
          st_covered_by_sql(
            shape_column_sql(shape),
            select_geometry_sql(geographic_item.id)
          )
        }

        q = a.first
        if a.count > 1
          a[1..].each { |i| q = q.or(i) }
        end

        q = 'FALSE' if q.blank?
        where(q) # .not_including(geographic_items)
      end
    end
    # rubocop:enable Metrics/MethodLength

    # @param [RGeo::Point] center in lon/lat
    # @param [Integer] radius of the circle, in meters
    # @param [Integer] buffer_resolution: the number of sides of the polygon
    #                  approximation per quarter circle
    # @return [RGeo::Polygon] A polygon approximation of the desired circle, in
    #                         geographic coordinates
    def circle(center, radius, buffer_resolution = 8)
      circle_wkb = select_value(
        st_buffer_sql(
          st_geography_from_text_sql(
            "POINT (#{center.lon} #{center.lat})",
          ),
          radius,
          num_seg_quarter_circle: buffer_resolution
        )
      )

      make_valid_non_anti_meridian_crossing_shape(
        Gis::FACTORY.parse_wkb(circle_wkb).as_text
      )
    end

    #
    # Other
    #

    # @param [RGeo::Point] point
    # @return [Hash]
    #   as per #inferred_geographic_name_hierarchy but for Rgeo point
    def point_inferred_geographic_name_hierarchy(point)
      where(superset_of_sql(st_geom_from_text_sql(point.to_s)))
      .order(cached_total_area: :ASC)
      .first&.inferred_geographic_name_hierarchy
    end

    def geography_as_geometry
      Arel.sql('geography::geometry')
    end

    # @param [GeographicItem]
    # @return [Scope]
    def not_including(geographic_items)
      where.not(id: geographic_items)
    end

  end # class << self

  # @return [Hash]
  #    a geographic_name_classification or empty Hash
  # This is a quick approach that works only when
  # the geographic_item is linked explicitly to a GeographicArea.
  #
  # !! Note that it is not impossible for a GeographicItem to be linked
  # to > 1 GeographicArea, in that case we are assuming that all are
  # equally refined, this might not be the case in the future because
  # of how the GeographicArea gazetteer is indexed.
  def quick_geographic_name_hierarchy
    geographic_areas.order(:id).each do |ga|
      h = ga.geographic_name_classification # not quick enough !!
      return h if h.present?
    end

    {}
  end

  # @return [Hash]
  #   a geographic_name_classification (see GeographicArea) inferred by
  #   finding the smallest area covering this GeographicItem, in the most
  #   accurate gazetteer and using it to return country/state/county. See also
  #   the logic in filling in missing levels in GeographicArea.
  def inferred_geographic_name_hierarchy
    if small_area = covering_geographic_areas
      .joins(:geographic_areas_geographic_items)
      .merge(GeographicAreasGeographicItem.ordered_by_data_origin)
      .ordered_by_area
      .first

      return small_area.geographic_name_classification
    end

    {}
  end

  def geographic_name_hierarchy
    a = quick_geographic_name_hierarchy # quick; almost never the case, UI not setup to do this
    return a if a.present?
    inferred_geographic_name_hierarchy # slow
  end

  # @return [Scope]
  #   the Geographic Areas that cover (gis) this geographic item
  def covering_geographic_areas
    GeographicArea
      .joins(:geographic_items)
      .includes(:geographic_area_type)
      .joins(
        "JOIN (#{GeographicItem.superset_of_union_of(id).to_sql}) AS j ON " \
        'geographic_items.id = j.id'
      )
  end

  # @param [GeographicItem] geographic_item
  # @return [Double] distance in meters
  # Works with changed and non persisted objects
  def st_distance_to_geographic_item(geographic_item)
    if persisted? && !changed?
      a = self.class.select_geography_sql(id)
    else
      a = self.class.st_geography_from_text_sql(geo_object.to_s)
    end

    if geographic_item.persisted? && !geographic_item.changed?
      b = self.class.select_geography_sql(geographic_item.id)
    else
      b = self.class.st_geography_from_text_sql(geographic_item.geo_object.to_s)
    end

    self.class.select_value(
      self.class.st_distance_sql(a, b)
    )
  end

  # @return [String]
  #   a WKT POINT representing the geometry centroid of the geographic item
  def st_centroid
    select_from_self(
      self.class.st_as_text_sql(
        self.class.st_centroid_sql(self.class.geography_as_geometry)
      )
    )['st_astext']
  end

  # @return [RGeo::Geographic::ProjectedPointImpl]
  #    representing the geometric centroid of this geographic item
  def centroid
    return geo_object if geo_object_type == :point

    Gis::FACTORY.parse_wkt(st_centroid)
  end

  # @return [Array]
  #   the lat, long, as STRINGs for the geometric centroid of this geographic
  #   item
  #   Meh- this: https://postgis.net/docs/en/ST_MinimumBoundingRadius.html
  def center_coords
    select_from_self(
      self.class.lat_long_sql(:latitude),
      self.class.lat_long_sql(:longitude)
    ).values
  end

  # !!TODO: migrate these to use native column calls

  # @param [geo_object]
  # @return [Boolean]
  def contains?(target_geo_object)
    return nil if target_geo_object.nil?
    self.geo_object.contains?(target_geo_object)
  end

  # @param [geo_object]
  # @return [Boolean]
  def within?(target_geo_object)
    self.geo_object.within?(target_geo_object)
  end

  # @param [geo_object]
  # @return [Boolean]
  def intersects?(target_geo_object)
    self.geo_object.intersects?(target_geo_object)
  end

  # @return [Hash] in GeoJSON format
  def to_geo_json
    JSON.parse(
      select_from_self(
        self.class.st_as_geo_json_sql(self.class.arel_table[:geography])
      )['st_asgeojson']
    )
  end

  # @return [Hash]
  #   the shape as a GeoJSON Feature with some item metadata
  def to_geo_json_feature
    {
      'type' => 'Feature',
      'geometry' => to_geo_json,
      'properties' => {
        'geographic_item' => {
          'id' => id
        }
      }
    }
  end

  # @param value [String] geojson like:
  #   '{"type":"Feature","geometry":{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}'
  #
  #   '{"type":"Feature","geometry":{"type":"Polygon","coordinates":"[[[-125.29394388198853, 48.584480409793],
  #      [-67.11035013198853, 45.09937589848195],[-80.64550638198853, 25.01924647619111],[-117.55956888198853,
  #      32.5591595028449],[-125.29394388198853, 48.584480409793]]]"},"properties":{}}'
  #
  #  '{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}'
  #
  def shape=(value)
    return if value.blank?

    begin
      geom = RGeo::GeoJSON.decode(value, json_parser: :json, geo_factory: Gis::FACTORY)
    rescue RGeo::Error::InvalidGeometry => e
      errors.add(:base, "invalid geometry: #{e}")
      return
    end

    object = nil

    s = geom.respond_to?(:geometry) ? geom.geometry.to_s : geom.to_s

    begin
      object = Gis::FACTORY.parse_wkt(s)
    rescue RGeo::Error::InvalidGeometry => e
      errors.add(:self, "Shape value is an Invalid Geometry: '#{e}'")
      return
    end

    write_attribute(:geography, object)
  end

  # @return [String] wkt
  def to_wkt
    select_from_self(
      self.class.st_as_text_sql(self.class.geography_as_geometry)
    )['st_astext']
  end

  # @return [Float] area in square meters, calculated
  # TODO: share with world
  def area
    select_from_self(
      self.class.st_area_sql(self.class.arel_table[:geography])
    )['st_area']
  end

  # TODO: This is bad, while internal use of ONE_WEST_MEAN is consistent it is in-accurate given the vast differences of radius vs. lat/long position.
  # When we strike the error-polygon from radius we should remove this
  #
  # Use case is returning the radius from a circle we calculated via buffer for error-polygon creation.
  def radius
    r = select_from_self(
      self.class.st_minimum_bounding_radius_sql(
        self.class.geography_as_geometry
      )
    )['st_minimumboundingradius'].split(',').last.chop.to_f

    (r * Utilities::Geo::ONE_WEST_MEAN).to_i
  end

  # Convention is to store in PostGIS in CCW
  # @return Array [Boolean]
  #   false - cw
  #   true - ccw (preferred), except see donuts
  def orientations
    if geography_is_multi_polygon?
      ApplicationRecord.connection.execute(
            "SELECT ST_IsPolygonCCW(a.geom) as is_ccw
              FROM ( SELECT b.id, (ST_Dump(p_geom)).geom AS geom
                  FROM (SELECT id, geography::geometry AS p_geom FROM geographic_items where id = #{id}) AS b
            ) AS a;").collect{|a| a['is_ccw']}
    elsif geography_is_polygon?
      ApplicationRecord.connection.execute(
        "SELECT ST_IsPolygonCCW(geography::geometry) as is_ccw \
        FROM geographic_items where  id = #{id};"
      ).collect { |a| a['is_ccw'] }
    else
      []
    end
  end

  # @return Boolean
  #   looks at all orientations
  #   if they follow the pattern [true, false, ... <all false>] then `true`, else `false`
  # !! Does not confirm that shapes are nested !!
  def is_basic_donut?
    a = orientations
    b = a.shift
    return false unless b

    a.uniq! == [false]
  end

  def st_is_valid
    select_from_self(
      self.class.st_is_valid_sql(
        self.class.geography_as_geometry
      )
    )['st_isvalid']
  end

  def st_is_valid_reason
    select_from_self(
      self.class.st_is_valid_reason_sql(
        self.class.geography_as_geometry
      )
    )['st_isvalidreason']
  end

  # @return [Symbol]
  #   the specific type of geography: :point, :multi_polygon, etc.
  def geo_object_type
    return geography.geometry_type.type_name.underscore.to_sym if geography

    nil
  end

  # @return [RGeo instance, nil]
  #  the Rgeo shape
  def geo_object
    geography
  end

  private

  # @param [String or Symbol] shape, the kind of shape you want, e.g. :polygon
  # @return [Arel::Nodes::Case]
  #   A Case statement that selects the geography column if that column is of
  #   type `shape`, otherwise NULL
  def self.shape_column_sql(shape)
    st_shape = 'ST_' + shape.to_s.camelize

    Arel::Nodes::Case.new(st_geometry_type(arel_table[:geography]))
      .when(st_shape.to_sym).then(arel_table[:geography])
      .else(Arel.sql('NULL'))
  end

  def self.shape_is_type(shape)
    st_shape = 'ST_' + shape.to_s.camelize

    Arel::Nodes::Case.new(st_geometry_type(arel_table[:geography]))
      .when(st_shape.to_sym).then(Arel.sql('TRUE'))
      .else(Arel.sql('FALSE'))
  end

  def geography_is_point?
    geo_object_type == :point
  end

  def geography_is_polygon?
    geo_object_type == :polygon
  end

  def geography_is_multi_polygon?
    geo_object_type == :multi_polygon
  end

  def select_from_self(*named_function)
    # This is faster than GeographicItem.select(...)
    ActiveRecord::Base.connection.execute(
      self.class.arel_table
        .project(*named_function)
        .where(self.class.arel_table[:id].eq(id))
        .to_sql
    ).to_a.first
  end

  def self.select_value(named_function)
    # This is faster than select(...)
    ActiveRecord::Base.connection.select_value(
      'SELECT ' +
      named_function.to_sql
    )
  end

  def align_winding
    if orientations.flatten.include?(false)
      if (geography_is_polygon? || geography_is_multi_polygon?)
        ApplicationRecord.connection.execute(
          "UPDATE geographic_items SET geography = ST_ForcePolygonCCW(geography::geometry)
            WHERE id = #{self.id};"
          )
      end
    end
  end

  # Crude debuging helper, write the shapes
  # to a png
  def self.debug_draw(geographic_item_ids = [])
    return false if geographic_item_ids.empty?
    sql = "SELECT ST_AsPNG(
        ST_AsRaster(
          (SELECT ST_Union(geography::geometry) from geographic_items where id IN (" + geographic_item_ids.join(',') + ")), 1920, 1080
      )
      ) png;"

    # ST_Buffer( multi_polygon::geometry, 0, 'join=bevel'),
    #     1920,
    #     1080)


    result = ActiveRecord::Base.connection.execute(sql).first['png']
    r = ActiveRecord::Base.connection.unescape_bytea(result)

    prefix = if geographic_item_ids.size > 10
               'multiple'
              else
                geographic_item_ids.join('_')
              end

    n = prefix + '_debug.draw.png'

    # Open the file in binary write mode ("wb")
    File.open(n, 'wb') do |file|
      # Write the binary data to the file
      file.write(r)
    end
  end

  # def png

  #   if ids = Otu.joins(:cached_map_items).first.cached_map_items.pluck(:geographic_item_id)

  #     sql = "SELECT ST_AsPNG(
  #    ST_AsRaster(
  #        ST_Buffer( multi_polygon::geometry, 0, 'join=bevel'),
  #            1024,
  #            768)
  #     ) png
  #        from geographic_items where id IN (" + ids.join(',') + ');'

  #     # hack, not the best way to unpack result
  #     result = ActiveRecord::Base.connection.execute(sql).first['png']
  #     r = ActiveRecord::Base.connection.unescape_bytea(result)

  #     send_data r, filename: 'foo.png', type: 'imnage/png'

  #   else
  #     render json: {foo: false}
  #   end
  # end

  def set_cached
    update_column(:cached_total_area, area)
  end

  def some_data_is_provided
    errors.add(:base, 'No shape provided or provided shape is invalid') if
      geography.nil?
  end

  def normalize_point_longitude
    return if !geography_is_point?

    if geography.x < -180.0 || geography.x > 180.0
      new_lon = geography.x % 360.0
      new_lon = new_lon - 360.0 if new_lon > 180.0
      self.geography = Gis::FACTORY.point(new_lon, geography.y)
    end
  end

  # @return [Boolean] true if wkt needs to be longitude-translated to make its
  # longitudes be in the interval (0, 360).
  # This is a bit of a hack: whether or not to shift wkt depends on whether or
  # not its longitudes are already in the range (0,360), which indicates to rgeo
  # that hemisphere-crossing lines cross the anti-meridian, not the meridian.
  # (We make the assumption that there aren't coordinates in both (180, 360) and
  # (-180, 0), which is actually possible with hand-entered wkt, and not easy
  # to deal with.)
  # TODO: find a more canonical/rgeo way to do this?
  # TODO: support other wkt shape types as needed. MultiPolygon covers all
  # polygon inputs from leaflet, the main case.
  def self.wkt_needs_longitude_translation(wkt)
    # Use a cartesian factory that doesn't automagically normalize its
    # longitude inputs, as Gis::FACTORY does.
    s = RGeo::Cartesian.simple_factory.parse_wkt(wkt)

    translate_longitudes = true
    # Currently this is intended to support Leaflet polygons.
    if (s.geometry_type.type_name == 'MultiPolygon' && s.count == 1)
      translate_longitudes =
        s[0].exterior_ring.points.map(&:x).any? { |l| l < 0 }
    end

    translate_longitudes
  end

  def self.geography_cast(sql)
    # Arel::Nodes::NamedFunction.new('CAST', [sql.as('geography')])
    Arel.sql(
      Arel::Nodes::Grouping.new(sql).to_sql + '::geography'
    )
  end

  def self.geometry_cast(sql)
    # specs fail using:
    # Arel::Nodes::NamedFunction.new('CAST', [sql.as('geometry')])
    Arel.sql(
      Arel::Nodes::Grouping.new(sql).to_sql + '::geometry'
    )
  end

  def self.quote_string(s)
    ActiveRecord::Base.connection.quote_string(s)
  end

  # From the docs: Splits string at occurrences of delimiter and returns the
  # n'th field (counting from one), or when n is negative, returns the
  # |n|'th-from-last field.
  # !! postgresql-specific
  def self.split_part(s, delimiter, n)
    Arel::Nodes::NamedFunction.new(
        'split_part',
        [
          Arel::Nodes.build_quoted(s),
          Arel::Nodes.build_quoted(delimiter),
          Arel::Nodes.build_quoted(n)
        ]
      )
  end

end

#no_cachedBoolean

Returns When true cached values are not built.

Returns:

  • (Boolean)

    When true cached values are not built



33
34
35
# File 'app/models/geographic_item.rb', line 33

def no_cached
  @no_cached
end

#shapeBoolean, RGeo object

TODO: WHY! boolean not nil, or object Used to build geographic items from a shape [ of what class ] !?

Returns:

  • (Boolean, RGeo object)


29
30
31
# File 'app/models/geographic_item.rb', line 29

def shape
  @shape
end

Class Method Details

.anti_meridian_crossing_make_valid_sql(wkt) ⇒ Object

Assumes wkt crosses the anti-meridian. !! Note you must apply St_ShiftLongitude to the return value to have the correct interpretation of the return value’s relation to the anti-meridian (cf. anti_meridian_spec.rb).



369
370
371
372
373
374
375
376
377
# File 'app/models/geographic_item.rb', line 369

def anti_meridian_crossing_make_valid_sql(wkt)
  st_make_valid_sql(
    st_shift_longitude_sql(
      st_geom_from_text_sql(
        wkt
      )
    )
  )
end

.buffer_crosses_anti_meridian?(wkt, distance) ⇒ Boolean

Returns whether or not the radius-buffer of wkt-point intersects the anti-meridian.

Parameters:

  • wkt (String)
  • buffer (Integer)

    distance

Returns:

  • (Boolean)

    whether or not the radius-buffer of wkt-point intersects the anti-meridian



441
442
443
444
445
446
447
448
449
450
451
452
# File 'app/models/geographic_item.rb', line 441

def buffer_crosses_anti_meridian?(wkt, distance)
  wkt = quote_string(wkt)
  select_value(
    st_intersects_sql(
      st_buffer_sql(
        st_geography_from_text_sql(wkt),
        distance
      ),
      st_geography_from_text_sql(ANTI_MERIDIAN)
    )
  )
end

.circle(center, radius, buffer_resolution = 8) ⇒ RGeo::Polygon

Returns A polygon approximation of the desired circle, in geographic coordinates.

Parameters:

  • center (RGeo::Point)

    in lon/lat

  • radius (Integer)

    of the circle, in meters

  • buffer_resolution: (Integer)

    the number of sides of the polygon approximation per quarter circle

Returns:

  • (RGeo::Polygon)

    A polygon approximation of the desired circle, in geographic coordinates



826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
# File 'app/models/geographic_item.rb', line 826

def circle(center, radius, buffer_resolution = 8)
  circle_wkb = select_value(
    st_buffer_sql(
      st_geography_from_text_sql(
        "POINT (#{center.lon} #{center.lat})",
      ),
      radius,
      num_seg_quarter_circle: buffer_resolution
    )
  )

  make_valid_non_anti_meridian_crossing_shape(
    Gis::FACTORY.parse_wkb(circle_wkb).as_text
  )
end

.covered_by_wkt_sql(wkt) ⇒ NamedFunction

covered by this WKT

Returns:

  • (NamedFunction)

    SQL fragment limiting geographic items to those



622
623
624
625
626
627
628
629
630
631
632
633
634
635
# File 'app/models/geographic_item.rb', line 622

def covered_by_wkt_sql(wkt)
  wkt = quote_string(wkt)
  if crosses_anti_meridian?(wkt)
    translate_longitudes = wkt_needs_longitude_translation(wkt)
    wkt_geom = st_geom_from_text_sql(wkt)
    subset_of_shifted_anti_meridian_shape_sql(
      translate_longitudes ? st_shift_longitude_sql(wkt_geom) : wkt_geom
    )
  else
    subset_of_sql(
      st_geom_from_text_sql(wkt)
    )
  end
end

.crosses_anti_meridian?(wkt) ⇒ Boolean

Returns whether or not the wkt intersects with the anti-meridian.

Parameters:

  • wkt (String)

Returns:

  • (Boolean)

    whether or not the wkt intersects with the anti-meridian



426
427
428
429
430
431
432
433
434
# File 'app/models/geographic_item.rb', line 426

def crosses_anti_meridian?(wkt)
  wkt = quote_string(wkt)
  select_value(
    st_intersects_sql(
      st_geography_from_text_sql(wkt),
      st_geography_from_text_sql(ANTI_MERIDIAN)
    )
  )
end

.crosses_anti_meridian_by_id?(*ids) ⇒ Boolean

Unused, kept for reference

Parameters:

  • ids (Integer)

Returns:

  • (Boolean)

    whether or not any GeographicItem passed intersects the anti-meridian !! StrongParams security considerations This is our first line of defense against queries that define multiple shapes, one or more of which crosses the anti-meridian. In this case the current TW strategy within the UI is to abandon the search, and prompt the user to refactor the query.



463
464
465
466
467
# File 'app/models/geographic_item.rb', line 463

def crosses_anti_meridian_by_id?(*ids)
  q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \
        'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN
  GeographicItem.find_by_sql(q).first.r
end

.debug_draw(geographic_item_ids = []) ⇒ Object (private)

Crude debuging helper, write the shapes to a png



1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
# File 'app/models/geographic_item.rb', line 1205

def self.debug_draw(geographic_item_ids = [])
  return false if geographic_item_ids.empty?
  sql = "SELECT ST_AsPNG(
      ST_AsRaster(
        (SELECT ST_Union(geography::geometry) from geographic_items where id IN (" + geographic_item_ids.join(',') + ")), 1920, 1080
    )
    ) png;"

  # ST_Buffer( multi_polygon::geometry, 0, 'join=bevel'),
  #     1920,
  #     1080)


  result = ActiveRecord::Base.connection.execute(sql).first['png']
  r = ActiveRecord::Base.connection.unescape_bytea(result)

  prefix = if geographic_item_ids.size > 10
             'multiple'
            else
              geographic_item_ids.join('_')
            end

  n = prefix + '_debug.draw.png'

  # Open the file in binary write mode ("wb")
  File.open(n, 'wb') do |file|
    # Write the binary data to the file
    file.write(r)
  end
end

.geography_as_geometryObject



855
856
857
# File 'app/models/geographic_item.rb', line 855

def geography_as_geometry
  Arel.sql('geography::geometry')
end

.geography_cast(sql) ⇒ Object (private)



1304
1305
1306
1307
1308
1309
# File 'app/models/geographic_item.rb', line 1304

def self.geography_cast(sql)
  # Arel::Nodes::NamedFunction.new('CAST', [sql.as('geography')])
  Arel.sql(
    Arel::Nodes::Grouping.new(sql).to_sql + '::geography'
  )
end

.geometry_cast(sql) ⇒ Object (private)



1311
1312
1313
1314
1315
1316
1317
# File 'app/models/geographic_item.rb', line 1311

def self.geometry_cast(sql)
  # specs fail using:
  # Arel::Nodes::NamedFunction.new('CAST', [sql.as('geometry')])
  Arel.sql(
    Arel::Nodes::Grouping.new(sql).to_sql + '::geometry'
  )
end

.intersecting(shape, *geographic_item_ids) ⇒ Object

This is a geographic intersect, not geometric



676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
# File 'app/models/geographic_item.rb', line 676

def intersecting(shape, *geographic_item_ids)
  shape = shape.to_s.downcase
  if shape == 'any'
    pieces = []
    SHAPE_TYPES.each { |shape|
      pieces.push(
        intersecting(shape, geographic_item_ids).to_a
      )
    }

    # @TODO change 'id in (?)' to some other sql construct
    GeographicItem.where(id: pieces.flatten.map(&:id))
  else
    a = geographic_item_ids.flatten.collect { |geographic_item_id|
      # seems like we want this: http://danshultz.github.io/talks/mastering_activerecord_arel/#/15/2
      st_intersects_sql(
        shape_column_sql(shape),
        select_geography_sql(geographic_item_id)
      )
    }

    q = a.first
    if a.count > 1
      a[1..].each { |i| q = q.or(i) }
    end

    where(q)
  end
end

.intersecting_radius_of_wkt_sql(wkt, distance) ⇒ NamedFunction

!! This is computed in 2d

Parameters:

  • wkt (String)
  • distance (Integer)

    (meters)

Returns:

  • (NamedFunction)

    Shapes whose distance to wkt is less than ‘distance`



552
553
554
555
556
557
558
559
# File 'app/models/geographic_item.rb', line 552

def intersecting_radius_of_wkt_sql(wkt, distance)
  wkt = quote_string(wkt)
  st_dwithin_sql(
    st_geography_from_text_sql(wkt),
    arel_table[:geography],
    distance
  )
end

.items_as_one_geometry_sql(*geographic_item_ids) ⇒ SelectManager for one id, Arel::Nodes::SqlLiteral for more than one

Parameters:

  • geographic_item_ids (Integer, Array of Integer)

Returns:

  • (SelectManager for one id, Arel::Nodes::SqlLiteral for more than one)


610
611
612
613
614
615
616
617
# File 'app/models/geographic_item.rb', line 610

def items_as_one_geometry_sql(*geographic_item_ids)
  geographic_item_ids.flatten! # *ALWAYS* reduce the pile to a single level of ids
  if geographic_item_ids.count == 1
    select_geometry_sql(geographic_item_ids.first)
  else
    single_geometry_sql(geographic_item_ids)
  end
end

.lat_long_sql(choice) ⇒ Arel::Nodes::NamedFunction

Returns a fragment returning either latitude or longitude columns.

Parameters:

  • choice, (Symbol)

    either :latitude or :longitude

Returns:

  • (Arel::Nodes::NamedFunction)

    a fragment returning either latitude or longitude columns



496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'app/models/geographic_item.rb', line 496

def lat_long_sql(choice)
  return nil unless [:latitude, :longitude].include?(choice)
  f = 'D.DDDDDD'
  v = (choice == :latitude ? 1 : 2)

  split_part(
    st_as_lat_lon_text_sql(
      st_centroid_sql(geography_as_geometry),
      f
    ),
    ' ',
    v
  ).as(choice.to_s)
end

.make_valid_non_anti_meridian_crossing_shape(wkt) ⇒ Object



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'app/models/geographic_item.rb', line 379

def make_valid_non_anti_meridian_crossing_shape(wkt)
  if crosses_anti_meridian?(wkt)
    split_along_anti_meridian(wkt, make_valid: true)
  else
    wkb = select_value(
      st_make_valid_sql(
        st_geom_from_text_sql(
          wkt
        )
      )
    )

    ::Gis::FACTORY.parse_wkb(wkb)
  end
end

.not_including(geographic_items) ⇒ Scope

Parameters:

Returns:

  • (Scope)


861
862
863
# File 'app/models/geographic_item.rb', line 861

def not_including(geographic_items)
  where.not(id: geographic_items)
end

.point_inferred_geographic_name_hierarchy(point) ⇒ Hash

Returns as per #inferred_geographic_name_hierarchy but for Rgeo point.

Parameters:

  • point (RGeo::Point)

Returns:

  • (Hash)

    as per #inferred_geographic_name_hierarchy but for Rgeo point



849
850
851
852
853
# File 'app/models/geographic_item.rb', line 849

def point_inferred_geographic_name_hierarchy(point)
  where(superset_of_sql(st_geom_from_text_sql(point.to_s)))
  .order(cached_total_area: :ASC)
  .first&.inferred_geographic_name_hierarchy
end

.quote_string(s) ⇒ Object (private)



1319
1320
1321
# File 'app/models/geographic_item.rb', line 1319

def self.quote_string(s)
  ActiveRecord::Base.connection.quote_string(s)
end

.select_geography_sql(geographic_item_id) ⇒ SelectManager

Returns a SQL select statement that returns the geography for the geographic_item with the specified id.

Parameters:

  • (Integer, String)

Returns:

  • (SelectManager)

    a SQL select statement that returns the geography for the geographic_item with the specified id



487
488
489
490
491
# File 'app/models/geographic_item.rb', line 487

def select_geography_sql(geographic_item_id)
  arel_table
    .project(arel_table[:geography])
    .where(arel_table[:id].eq(geographic_item_id))
end

.select_geometry_sql(geographic_item_id) ⇒ SelectManager

Returns a SQL select statement that returns the geometry for the geographic_item with the specified id.

Parameters:

  • (Integer, String)

Returns:

  • (SelectManager)

    a SQL select statement that returns the geometry for the geographic_item with the specified id



477
478
479
480
481
# File 'app/models/geographic_item.rb', line 477

def select_geometry_sql(geographic_item_id)
  arel_table
    .project(geography_as_geometry)
    .where(arel_table[:id].eq(geographic_item_id))
end

.select_value(named_function) ⇒ Object (private)



1184
1185
1186
1187
1188
1189
1190
# File 'app/models/geographic_item.rb', line 1184

def self.select_value(named_function)
  # This is faster than select(...)
  ActiveRecord::Base.connection.select_value(
    'SELECT ' +
    named_function.to_sql
  )
end

.shape_column_sql(shape) ⇒ Arel::Nodes::Case (private)

Returns A Case statement that selects the geography column if that column is of type ‘shape`, otherwise NULL.

Parameters:

  • shape, (String or Symbol)

    the kind of shape you want, e.g. :polygon

Returns:

  • (Arel::Nodes::Case)

    A Case statement that selects the geography column if that column is of type ‘shape`, otherwise NULL



1146
1147
1148
1149
1150
1151
1152
# File 'app/models/geographic_item.rb', line 1146

def self.shape_column_sql(shape)
  st_shape = 'ST_' + shape.to_s.camelize

  Arel::Nodes::Case.new(st_geometry_type(arel_table[:geography]))
    .when(st_shape.to_sym).then(arel_table[:geography])
    .else(Arel.sql('NULL'))
end

.shape_is_type(shape) ⇒ Object (private)



1154
1155
1156
1157
1158
1159
1160
# File 'app/models/geographic_item.rb', line 1154

def self.shape_is_type(shape)
  st_shape = 'ST_' + shape.to_s.camelize

  Arel::Nodes::Case.new(st_geometry_type(arel_table[:geography]))
    .when(st_shape.to_sym).then(Arel.sql('TRUE'))
    .else(Arel.sql('FALSE'))
end

.single_geometry_sql(*geographic_item_ids) ⇒ Arel::Nodes::SqlLiteral

Returns one or more geographic items combined as a single geometry in a column ‘single_geometry’

Parameters:

  • geographic_item_ids (Integer, Array of Integer)

Returns:

  • (Arel::Nodes::SqlLiteral)

    returns one or more geographic items combined as a single geometry in a column ‘single_geometry’



592
593
594
595
596
597
598
599
600
601
602
603
604
605
# File 'app/models/geographic_item.rb', line 592

def single_geometry_sql(*geographic_item_ids)
  geographic_item_ids.flatten!

  Arel.sql(
    "SELECT ST_Collect(f.the_geom) AS single_geometry
      FROM (
        SELECT (
          ST_DUMP(geography::geometry)
        ).geom AS the_geom
        FROM geographic_items
        WHERE id in (#{geographic_item_ids.join(',')})
      ) AS f"
  )
end

.split_along_anti_meridian(wkt, make_valid: false) ⇒ Object

intersect the anti-meridian. Slightly lossy (has to be), and may turn polygon into multi-polygon, etc. Assumes wkt intersects the anti-meridian.

Parameters:

  • wkt (String)

Returns:

  • RGeo shape for wkt expressed as a union of pieces none of which



400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
# File 'app/models/geographic_item.rb', line 400

def split_along_anti_meridian(wkt, make_valid: false)
  wkt = quote_string(wkt)
  # Intended to be the exterior of a tiny buffer around the anti-meridian,
  # expressed as two sheets/near-hemispheres that meet at long=0=360.
  anti_meridian_exterior = 'MULTIPOLYGON(
    ((0 -89.999999, 179.999999 -89.999999, 179.999999 89.999999, 0 89.999999, 0 -89.999999)),
    ((180.000001 -89.999999, 360 -89.999999, 360 89.999999, 180.000001 89.999999, 180.000001 -89.999999))
  )'

  s = make_valid ?
    anti_meridian_crossing_make_valid_sql(wkt) :
    st_shift_longitude_sql(st_geom_from_text_sql(wkt))

  wkb = select_value(
    st_intersection_sql(
      s,
      st_geom_from_text_sql(anti_meridian_exterior)
    )
  )

  ::Gis::FACTORY.parse_wkb(wkb)
end

.split_part(s, delimiter, n) ⇒ Object (private)

From the docs: Splits string at occurrences of delimiter and returns the n’th field (counting from one), or when n is negative, returns the |n|‘th-from-last field. !! postgresql-specific



1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
# File 'app/models/geographic_item.rb', line 1327

def self.split_part(s, delimiter, n)
  Arel::Nodes::NamedFunction.new(
      'split_part',
      [
        Arel::Nodes.build_quoted(s),
        Arel::Nodes.build_quoted(delimiter),
        Arel::Nodes.build_quoted(n)
      ]
    )
end

.st_area_sql(shape) ⇒ Object



177
178
179
180
181
182
183
# File 'app/models/geographic_item.rb', line 177

def st_area_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_Area', [
      shape
    ]
  )
end

.st_as_geo_json_sql(shape) ⇒ Object



250
251
252
253
254
255
256
# File 'app/models/geographic_item.rb', line 250

def st_as_geo_json_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_AsGeoJSON', [
      shape
    ]
  )
end

.st_as_lat_lon_text_sql(point_shape, format = '') ⇒ Object



341
342
343
344
345
346
347
348
# File 'app/models/geographic_item.rb', line 341

def st_as_lat_lon_text_sql(point_shape, format = '')
  Arel::Nodes::NamedFunction.new(
    'ST_AsLatLonText', [
      geometry_cast(point_shape),
      Arel::Nodes.build_quoted(format)
    ]
  )
end

.st_as_text_sql(shape) ⇒ Object



226
227
228
229
230
231
232
# File 'app/models/geographic_item.rb', line 226

def st_as_text_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_AsText', [
      shape
    ]
  )
end

.st_buffer_sql(shape, distance, num_seg_quarter_circle: 8) ⇒ Object



289
290
291
292
293
294
295
296
297
# File 'app/models/geographic_item.rb', line 289

def st_buffer_sql(shape, distance, num_seg_quarter_circle: 8)
  Arel::Nodes::NamedFunction.new(
    'ST_Buffer', [
      geography_cast(shape),
      Arel::Nodes.build_quoted(distance),
      Arel::Nodes.build_quoted(num_seg_quarter_circle)
    ]
  )
end

.st_buffer_st_within_sql(geographic_item_id, distance, buffer = 0) ⇒ NamedFunction

Returns Shapes whose ‘buffer` is within `distance` of geographic_item.

Parameters:

  • geographic_item_id (Integer)
  • distance (Number)

    (in meters) (positive only?!)

  • buffer: (Number)

    distance in meters to grow/shrink the shapes checked against (negative allowed)

Returns:

  • (NamedFunction)

    Shapes whose ‘buffer` is within `distance` of geographic_item



533
534
535
536
537
538
539
540
541
542
543
544
545
# File 'app/models/geographic_item.rb', line 533

def st_buffer_st_within_sql(geographic_item_id, distance, buffer = 0)
  # You can't always switch the buffer to the second argument, even when
  # distance is 0, without further assumptions (think of buffer being
  # large negative compared to geographic_item_id, but not another shape))
  st_dwithin_sql(
    st_buffer_sql(
      arel_table[:geography],
      buffer
    ),
    select_geography_sql(geographic_item_id),
    distance
  )
end

.st_centroid_sql(shape) ⇒ Object



281
282
283
284
285
286
287
# File 'app/models/geographic_item.rb', line 281

def st_centroid_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_Centroid', [
      shape
    ]
  )
end

.st_collect_sql(shape) ⇒ Object



202
203
204
205
206
207
208
# File 'app/models/geographic_item.rb', line 202

def st_collect_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_Collect', [
      shape
    ]
  )
end

.st_covered_by(shape, *geographic_items) ⇒ Scope

rubocop:disable Metrics/MethodLength against all types, ‘any_poly’ to check against ‘polygon’ or ‘multi_polygon’, or ‘any_line’ to check against ‘line_string’ or ‘multi_line_string’. GeographicItem, or an array of GeographicItem. one or more of geographic_items !! Returns geographic_item when geographic_item is of type ‘shape`

Parameters:

  • shape (String)

    can be any of SHAPE_TYPES, or ‘any’ to check

  • geographic_items (GeographicItem)

    Can be a single

Returns:

  • (Scope)

    of all GeographicItems of the given ‘shape` covered by



779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
# File 'app/models/geographic_item.rb', line 779

def st_covered_by(shape, *geographic_items)
  shape = shape.to_s.downcase
  case shape
  when 'any'
    part = []
    SHAPE_TYPES.each { |s|
      part.push(GeographicItem.st_covered_by(s, geographic_items).to_a)
    }
    # @TODO change 'id in (?)' to some other sql construct
    GeographicItem.where(id: part.flatten.map(&:id))

  when 'any_poly', 'any_line'
    part = []
    SHAPE_TYPES.each { |s|
      s = s.to_s
      if s.index(shape.gsub('any_', ''))
        part.push(GeographicItem.st_covered_by(s, geographic_items).to_a)
      end
    }
    # @TODO change 'id in (?)' to some other sql construct
    GeographicItem.where(id: part.flatten.map(&:id))

  else
    a = geographic_items.flatten.collect { |geographic_item|
      st_covered_by_sql(
        shape_column_sql(shape),
        select_geometry_sql(geographic_item.id)
      )
    }

    q = a.first
    if a.count > 1
      a[1..].each { |i| q = q.or(i) }
    end

    q = 'FALSE' if q.blank?
    where(q) # .not_including(geographic_items)
  end
end

.st_covered_by_sql(shape1, shape2) ⇒ Object



130
131
132
133
134
135
136
137
138
# File 'app/models/geographic_item.rb', line 130

def st_covered_by_sql(shape1, shape2)
  Arel::Nodes::NamedFunction.new(
    'ST_CoveredBy',
    [
      geometry_cast(shape1),
      geometry_cast(shape2)
    ]
  )
end

.st_covers(shape, *geographic_items) ⇒ Scope

rubocop:disable Metrics/MethodLength against all types, ‘any_poly’ to check against ‘polygon’ or ‘multi_polygon’, or ‘any_line’ to check against ‘line_string’ or ‘multi_line_string’. If this scope is given an Array of GeographicItems as a second parameter, it will return the ‘OR’ of each of the objects against the table. SELECT COUNT(*) FROM “geographic_items”

WHERE (ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)'))
       OR ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)')))

Parameters:

  • shape (String)

    can be any of SHAPE_TYPES, or ‘any’ to check

  • geographic_items (GeographicItem)

    or array of geographic_items to be tested.

Returns:

  • (Scope)

    of GeographicItems whose ‘shape` contains at least one of geographic_items. !! Returns geographic_item when geographic_item is of

    type `shape`
    


724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
# File 'app/models/geographic_item.rb', line 724

def st_covers(shape, *geographic_items)
  geographic_items.flatten! # in case there is a array of arrays, or multiple objects
  shape = shape.to_s.downcase
  case shape
  when 'any'
    part = []
    SHAPE_TYPES.each { |s|
      part.push(st_covers(s, geographic_items).to_a)
    }
    # TODO: change 'id in (?)' to some other sql construct
    where(id: part.flatten.map(&:id))

  when 'any_poly', 'any_line'
    part = []
    SHAPE_TYPES.each { |s|
      s = s.to_s
      if s.index(shape.gsub('any_', ''))
        part.push(st_covers(s, geographic_items).to_a)
      end
    }
    # TODO: change 'id in (?)' to some other sql construct
    where(id: part.flatten.map(&:id))

  else
    a = geographic_items.flatten.collect { |geographic_item|
      st_covers_sql(
        shape_column_sql(shape),
        select_geometry_sql(geographic_item.id)
      )
    }

    q = a.first
    if a.count > 1
      a[1..].each { |i| q = q.or(i) }
    end

    # This will prevent the invocation of *ALL* of the GeographicItems
    # if there are no GeographicItems in the request (see
    # CollectingEvent.name_hash(types)).
    q = 'FALSE' if q.blank?
    where(q) # .not_including(geographic_items)
  end
end

.st_covers_sql(shape1, shape2) ⇒ Object



101
102
103
104
105
106
107
108
109
# File 'app/models/geographic_item.rb', line 101

def st_covers_sql(shape1, shape2)
  Arel::Nodes::NamedFunction.new(
    'ST_Covers',
    [
      geometry_cast(shape1),
      geometry_cast(shape2)
    ]
  )
end

.st_distance_sql(shape1, shape2) ⇒ Object



168
169
170
171
172
173
174
175
# File 'app/models/geographic_item.rb', line 168

def st_distance_sql(shape1, shape2)
  Arel::Nodes::NamedFunction.new(
    'ST_Distance', [
      shape1,
      shape2
    ]
  )
end

.st_dump_sql(shape) ⇒ Object



194
195
196
197
198
199
200
# File 'app/models/geographic_item.rb', line 194

def st_dump_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_Dump', [
      shape
    ]
  )
end

.st_dwithin_sql(shape1, shape2, distance) ⇒ Object

True if the distance from shape1 to shape2 is less than ‘distance`. This is a geography dwithin, distance is in meters.



331
332
333
334
335
336
337
338
339
# File 'app/models/geographic_item.rb', line 331

def st_dwithin_sql(shape1, shape2, distance)
  Arel::Nodes::NamedFunction.new(
    'ST_DWithin', [
      geography_cast(shape1),
      geography_cast(shape2),
      Arel::Nodes.build_quoted(distance)
    ]
  )
end

.st_geography_from_text_sql(wkt) ⇒ Object Also known as: st_geog_from_text_sql



258
259
260
261
262
263
264
265
# File 'app/models/geographic_item.rb', line 258

def st_geography_from_text_sql(wkt)
  wkt = quote_string(wkt)
  Arel::Nodes::NamedFunction.new(
    'ST_GeographyFromText', [
      Arel::Nodes.build_quoted(wkt),
    ]
  )
end

.st_geometry_from_text_sql(wkt) ⇒ Object Also known as: st_geom_from_text_sql



269
270
271
272
273
274
275
276
277
# File 'app/models/geographic_item.rb', line 269

def st_geometry_from_text_sql(wkt)
  wkt = quote_string(wkt)
  Arel::Nodes::NamedFunction.new(
    'ST_GeometryFromText', [
      Arel::Nodes.build_quoted(wkt),
      Arel::Nodes.build_quoted(4326)
    ]
  )
end

.st_geometry_type(shape) ⇒ Object



234
235
236
237
238
239
240
# File 'app/models/geographic_item.rb', line 234

def st_geometry_type(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_GeometryType', [
      geometry_cast(shape)
    ]
  )
end

.st_intersection_sql(shape1, shape2) ⇒ Object

!! Keep in mind that you may get different results depending on if the inputs are geographies or geometries.



312
313
314
315
316
317
318
319
# File 'app/models/geographic_item.rb', line 312

def st_intersection_sql(shape1, shape2)
  Arel::Nodes::NamedFunction.new(
    'ST_Intersection', [
      shape1,
      shape2
    ]
  )
end

.st_intersects_sql(shape1, shape2) ⇒ Object

# !! Keep in mind that you may get different results depending on if the inputs are geographies or geometries.



301
302
303
304
305
306
307
308
# File 'app/models/geographic_item.rb', line 301

def st_intersects_sql(shape1, shape2)
  Arel::Nodes::NamedFunction.new(
    'ST_Intersects', [
      shape1,
      shape2
    ]
  )
end

.st_is_valid_reason_sql(shape) ⇒ Object



218
219
220
221
222
223
224
# File 'app/models/geographic_item.rb', line 218

def st_is_valid_reason_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_IsValidReason', [
      shape
    ]
  )
end

.st_is_valid_sql(shape) ⇒ Object



210
211
212
213
214
215
216
# File 'app/models/geographic_item.rb', line 210

def st_is_valid_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_IsValid', [
      shape
    ]
  )
end

.st_make_valid_sql(shape) ⇒ Object

Returns valid shapes unchanged. !! This will give the wrong result on anti-meridian-crossing shapes stored in Gis::FACTORY coordinates, use anti_meridian_crossing_make_valid instead in that case.



354
355
356
357
358
359
360
361
362
363
# File 'app/models/geographic_item.rb', line 354

def st_make_valid_sql(shape)
  # TODO add params once we're on GEOS >= 3.10, they're not used until then
  #params = "method=structure keepcollapsed=false"
  Arel::Nodes::NamedFunction.new(
    'ST_MakeValid', [
      geometry_cast(shape)
      #Arel::Nodes.build_quoted(params)
    ]
  )
end

.st_minimum_bounding_radius_sql(shape) ⇒ Object



242
243
244
245
246
247
248
# File 'app/models/geographic_item.rb', line 242

def st_minimum_bounding_radius_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_MinimumBoundingRadius', [
      shape
    ]
  )
end

.st_shift_longitude_sql(shape) ⇒ Object



321
322
323
324
325
326
327
# File 'app/models/geographic_item.rb', line 321

def st_shift_longitude_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_ShiftLongitude', [
      geometry_cast(shape)
    ]
  )
end

.st_union(geographic_item_scope) ⇒ Object

DEPRECATED, moved to ::Queries::GeographicItem



96
97
98
99
# File 'app/models/geographic_item.rb', line 96

def st_union(geographic_item_scope)
  select('ST_Union(geography::geometry) as st_union')
    .where(id: geographic_item_scope.pluck(:id))
end

.st_union_sql(shape) ⇒ Object

Intended here to be used as an aggregate function



186
187
188
189
190
191
192
# File 'app/models/geographic_item.rb', line 186

def st_union_sql(shape)
  Arel::Nodes::NamedFunction.new(
    'ST_Union', [
      shape
    ]
  )
end

.subset_of_shifted_anti_meridian_shape_sql(shape) ⇒ Object

Only called when shape crosses the anti-meridian !! shape must be pre-shifted to longitude-range (0, 360) !!



150
151
152
153
154
155
156
157
158
# File 'app/models/geographic_item.rb', line 150

def subset_of_shifted_anti_meridian_shape_sql(shape)
  st_covered_by_sql(
    # All database geographic_items are (!! should be !!) stored in our
    # Gis::FACTORY-enforced longitude range (-180, 180), so always need to
    # be shifted in this case to the range (0, 360).
    st_shift_longitude_sql(geography_as_geometry),
    shape
  )
end

.subset_of_sql(shape) ⇒ Object

True for those shapes that are subsets of shape.



141
142
143
144
145
146
# File 'app/models/geographic_item.rb', line 141

def subset_of_sql(shape)
  st_covered_by_sql(
    geography_as_geometry,
    shape
  )
end

.subset_of_union_of_sql(*geographic_item_ids) ⇒ Object

Note: !! If the target GeographicItem#id crosses the anti-meridian then you may/will get unexpected results.



162
163
164
165
166
# File 'app/models/geographic_item.rb', line 162

def subset_of_union_of_sql(*geographic_item_ids)
  subset_of_sql(
    items_as_one_geometry_sql(*geographic_item_ids)
  )
end

.superset_of_sql(shape) ⇒ Object

True for those shapes that cover shape.



112
113
114
115
116
117
# File 'app/models/geographic_item.rb', line 112

def superset_of_sql(shape)
  st_covers_sql(
    geography_as_geometry,
    shape
  )
end

.superset_of_union_of(*geographic_item_ids) ⇒ Scope

does not include any of geographic_item_ids

Returns:

  • (Scope)

    of items covering the union of geographic_item_ids;



121
122
123
124
125
126
127
128
# File 'app/models/geographic_item.rb', line 121

def superset_of_union_of(*geographic_item_ids)
  where(
    superset_of_sql(
      items_as_one_geometry_sql(*geographic_item_ids)
    )
  )
  .not_ids(*geographic_item_ids)
end

.with_collecting_event_through_georeferencesScope

This uses an Arel table approach, this is ultimately more decomposable if we need. Of use:

http://danshultz.github.io/talks/mastering_activerecord_arel  <- best
https://github.com/rails/arel
http://stackoverflow.com/questions/4500629/use-arel-for-a-nested-set-join-query-and-convert-to-activerecordrelation
http://rdoc.info/github/rails/arel/Arel/SelectManager
http://stackoverflow.com/questions/7976358/activerecord-arel-or-condition

Returns:

  • (Scope)

    GeographicItem



659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
# File 'app/models/geographic_item.rb', line 659

def with_collecting_event_through_georeferences
  geographic_items = GeographicItem.arel_table
  georeferences = Georeference.arel_table
  g1 = georeferences.alias('a')
  g2 = georeferences.alias('b')

  c = geographic_items.join(g1, Arel::Nodes::OuterJoin).on(geographic_items[:id].eq(g1[:geographic_item_id]))
    .join(g2, Arel::Nodes::OuterJoin).on(geographic_items[:id].eq(g2[:error_geographic_item_id]))

  GeographicItem.joins(# turn the Arel back into scope
                        c.join_sources # translate the Arel join to a join hash(?)
                      ).where(
                        g1[:id].not_eq(nil).or(g2[:id].not_eq(nil)) # returns a Arel::Nodes::Grouping
                      ).distinct
end

.within_radius_of_item(geographic_item_id, radius) ⇒ Object



523
524
525
# File 'app/models/geographic_item.rb', line 523

def within_radius_of_item(geographic_item_id, radius)
  where(within_radius_of_item_sql(geographic_item_id, radius))
end

.within_radius_of_item_sql(geographic_item_id, radius) ⇒ Scope

Returns of shapes within distance of (i.e. whose distance-buffer intersects) geographic_item_id.

Parameters:

  • geographic_item_id (Integer)
  • radius (Integer)

    in meters

Returns:

  • (Scope)

    of shapes within distance of (i.e. whose distance-buffer intersects) geographic_item_id



515
516
517
518
519
520
521
# File 'app/models/geographic_item.rb', line 515

def within_radius_of_item_sql(geographic_item_id, radius)
  st_dwithin_sql(
    select_geography_sql(geographic_item_id),
    arel_table[:geography],
    radius
  )
end

.within_radius_of_wkt_sql(wkt, distance) ⇒ NamedFunction

Returns Those items covered by the ‘distance`-buffer of wkt.

Parameters:

  • wkt (String)
  • distance (Integer)

    (meters)

Returns:

  • (NamedFunction)

    Those items covered by the ‘distance`-buffer of wkt



565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
# File 'app/models/geographic_item.rb', line 565

def within_radius_of_wkt_sql(wkt, distance)
  wkt = quote_string(wkt)

  if buffer_crosses_anti_meridian?(wkt, distance)
    subset_of_shifted_anti_meridian_shape_sql(
      # st_buffer_sql always has longitudes in (-180, 180) in this case, so
      # shift to (0, 360)
      st_shift_longitude_sql(
        st_buffer_sql(
          st_geography_from_text_sql(wkt),
          distance
        )
      )
    )
  else
    subset_of_sql(
      st_buffer_sql(
        st_geography_from_text_sql(wkt),
        distance
      )
    )
  end
end

.wkt_needs_longitude_translation(wkt) ⇒ Boolean (private)

longitudes be in the interval (0, 360). This is a bit of a hack: whether or not to shift wkt depends on whether or not its longitudes are already in the range (0,360), which indicates to rgeo that hemisphere-crossing lines cross the anti-meridian, not the meridian. (We make the assumption that there aren’t coordinates in both (180, 360) and (-180, 0), which is actually possible with hand-entered wkt, and not easy to deal with.) TODO: find a more canonical/rgeo way to do this? TODO: support other wkt shape types as needed. MultiPolygon covers all polygon inputs from leaflet, the main case.

Returns:

  • (Boolean)

    true if wkt needs to be longitude-translated to make its



1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
# File 'app/models/geographic_item.rb', line 1289

def self.wkt_needs_longitude_translation(wkt)
  # Use a cartesian factory that doesn't automagically normalize its
  # longitude inputs, as Gis::FACTORY does.
  s = RGeo::Cartesian.simple_factory.parse_wkt(wkt)

  translate_longitudes = true
  # Currently this is intended to support Leaflet polygons.
  if (s.geometry_type.type_name == 'MultiPolygon' && s.count == 1)
    translate_longitudes =
      s[0].exterior_ring.points.map(&:x).any? { |l| l < 0 }
  end

  translate_longitudes
end

Instance Method Details

#align_windingObject (private)



1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
# File 'app/models/geographic_item.rb', line 1192

def align_winding
  if orientations.flatten.include?(false)
    if (geography_is_polygon? || geography_is_multi_polygon?)
      ApplicationRecord.connection.execute(
        "UPDATE geographic_items SET geography = ST_ForcePolygonCCW(geography::geometry)
          WHERE id = #{self.id};"
        )
    end
  end
end

#areaFloat

TODO: share with world

Returns:

  • (Float)

    area in square meters, calculated



1057
1058
1059
1060
1061
# File 'app/models/geographic_item.rb', line 1057

def area
  select_from_self(
    self.class.st_area_sql(self.class.arel_table[:geography])
  )['st_area']
end

#center_coordsArray

Returns the lat, long, as STRINGs for the geometric centroid of this geographic item Meh- this: postgis.net/docs/en/ST_MinimumBoundingRadius.html.

Returns:



964
965
966
967
968
969
# File 'app/models/geographic_item.rb', line 964

def center_coords
  select_from_self(
    self.class.lat_long_sql(:latitude),
    self.class.lat_long_sql(:longitude)
  ).values
end

#centroidRGeo::Geographic::ProjectedPointImpl

Returns representing the geometric centroid of this geographic item.

Returns:

  • (RGeo::Geographic::ProjectedPointImpl)

    representing the geometric centroid of this geographic item



954
955
956
957
958
# File 'app/models/geographic_item.rb', line 954

def centroid
  return geo_object if geo_object_type == :point

  Gis::FACTORY.parse_wkt(st_centroid)
end

#contains?(target_geo_object) ⇒ Boolean

Parameters:

Returns:

  • (Boolean)


975
976
977
978
# File 'app/models/geographic_item.rb', line 975

def contains?(target_geo_object)
  return nil if target_geo_object.nil?
  self.geo_object.contains?(target_geo_object)
end

#covering_geographic_areasScope

Returns the Geographic Areas that cover (gis) this geographic item.

Returns:

  • (Scope)

    the Geographic Areas that cover (gis) this geographic item



911
912
913
914
915
916
917
918
919
# File 'app/models/geographic_item.rb', line 911

def covering_geographic_areas
  GeographicArea
    .joins(:geographic_items)
    .includes(:geographic_area_type)
    .joins(
      "JOIN (#{GeographicItem.superset_of_union_of(id).to_sql}) AS j ON " \
      'geographic_items.id = j.id'
    )
end

#geo_objectRGeo instance?

Returns the Rgeo shape.

Returns:

  • (RGeo instance, nil)

    the Rgeo shape



1136
1137
1138
# File 'app/models/geographic_item.rb', line 1136

def geo_object
  geography
end

#geo_object_typeSymbol

Returns the specific type of geography: :point, :multi_polygon, etc.

Returns:

  • (Symbol)

    the specific type of geography: :point, :multi_polygon, etc.



1128
1129
1130
1131
1132
# File 'app/models/geographic_item.rb', line 1128

def geo_object_type
  return geography.geometry_type.type_name.underscore.to_sym if geography

  nil
end

#geographic_name_hierarchyObject



903
904
905
906
907
# File 'app/models/geographic_item.rb', line 903

def geographic_name_hierarchy
  a = quick_geographic_name_hierarchy # quick; almost never the case, UI not setup to do this
  return a if a.present?
  inferred_geographic_name_hierarchy # slow
end

#geography_is_multi_polygon?Boolean (private)

Returns:

  • (Boolean)


1170
1171
1172
# File 'app/models/geographic_item.rb', line 1170

def geography_is_multi_polygon?
  geo_object_type == :multi_polygon
end

#geography_is_point?Boolean (private)

Returns:

  • (Boolean)


1162
1163
1164
# File 'app/models/geographic_item.rb', line 1162

def geography_is_point?
  geo_object_type == :point
end

#geography_is_polygon?Boolean (private)

Returns:

  • (Boolean)


1166
1167
1168
# File 'app/models/geographic_item.rb', line 1166

def geography_is_polygon?
  geo_object_type == :polygon
end

#inferred_geographic_name_hierarchyHash

Returns a geographic_name_classification (see GeographicArea) inferred by finding the smallest area covering this GeographicItem, in the most accurate gazetteer and using it to return country/state/county. See also the logic in filling in missing levels in GeographicArea.

Returns:

  • (Hash)

    a geographic_name_classification (see GeographicArea) inferred by finding the smallest area covering this GeographicItem, in the most accurate gazetteer and using it to return country/state/county. See also the logic in filling in missing levels in GeographicArea.



890
891
892
893
894
895
896
897
898
899
900
901
# File 'app/models/geographic_item.rb', line 890

def inferred_geographic_name_hierarchy
  if small_area = covering_geographic_areas
    .joins(:geographic_areas_geographic_items)
    .merge(GeographicAreasGeographicItem.ordered_by_data_origin)
    .ordered_by_area
    .first

    return small_area.geographic_name_classification
  end

  {}
end

#intersects?(target_geo_object) ⇒ Boolean

Parameters:

Returns:

  • (Boolean)


988
989
990
# File 'app/models/geographic_item.rb', line 988

def intersects?(target_geo_object)
  self.geo_object.intersects?(target_geo_object)
end

#is_basic_donut?Boolean

!! Does not confirm that shapes are nested !!

Returns:

  • (Boolean)

    Boolean looks at all orientations if they follow the pattern [true, false, … <all false>] then ‘true`, else `false`



1102
1103
1104
1105
1106
1107
1108
# File 'app/models/geographic_item.rb', line 1102

def is_basic_donut?
  a = orientations
  b = a.shift
  return false unless b

  a.uniq! == [false]
end

#normalize_point_longitudeObject (private)



1268
1269
1270
1271
1272
1273
1274
1275
1276
# File 'app/models/geographic_item.rb', line 1268

def normalize_point_longitude
  return if !geography_is_point?

  if geography.x < -180.0 || geography.x > 180.0
    new_lon = geography.x % 360.0
    new_lon = new_lon - 360.0 if new_lon > 180.0
    self.geography = Gis::FACTORY.point(new_lon, geography.y)
  end
end

#orientationsObject

Convention is to store in PostGIS in CCW



1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
# File 'app/models/geographic_item.rb', line 1081

def orientations
  if geography_is_multi_polygon?
    ApplicationRecord.connection.execute(
          "SELECT ST_IsPolygonCCW(a.geom) as is_ccw
            FROM ( SELECT b.id, (ST_Dump(p_geom)).geom AS geom
                FROM (SELECT id, geography::geometry AS p_geom FROM geographic_items where id = #{id}) AS b
          ) AS a;").collect{|a| a['is_ccw']}
  elsif geography_is_polygon?
    ApplicationRecord.connection.execute(
      "SELECT ST_IsPolygonCCW(geography::geometry) as is_ccw \
      FROM geographic_items where  id = #{id};"
    ).collect { |a| a['is_ccw'] }
  else
    []
  end
end

#quick_geographic_name_hierarchyHash

This is a quick approach that works only when the geographic_item is linked explicitly to a GeographicArea.

!! Note that it is not impossible for a GeographicItem to be linked to > 1 GeographicArea, in that case we are assuming that all are equally refined, this might not be the case in the future because of how the GeographicArea gazetteer is indexed.

Returns:

  • (Hash)

    a geographic_name_classification or empty Hash



876
877
878
879
880
881
882
883
# File 'app/models/geographic_item.rb', line 876

def quick_geographic_name_hierarchy
  geographic_areas.order(:id).each do |ga|
    h = ga.geographic_name_classification # not quick enough !!
    return h if h.present?
  end

  {}
end

#radiusObject

TODO: This is bad, while internal use of ONE_WEST_MEAN is consistent it is in-accurate given the vast differences of radius vs. lat/long position. When we strike the error-polygon from radius we should remove this

Use case is returning the radius from a circle we calculated via buffer for error-polygon creation.



1067
1068
1069
1070
1071
1072
1073
1074
1075
# File 'app/models/geographic_item.rb', line 1067

def radius
  r = select_from_self(
    self.class.st_minimum_bounding_radius_sql(
      self.class.geography_as_geometry
    )
  )['st_minimumboundingradius'].split(',').last.chop.to_f

  (r * Utilities::Geo::ONE_WEST_MEAN).to_i
end

#select_from_self(*named_function) ⇒ Object (private)



1174
1175
1176
1177
1178
1179
1180
1181
1182
# File 'app/models/geographic_item.rb', line 1174

def select_from_self(*named_function)
  # This is faster than GeographicItem.select(...)
  ActiveRecord::Base.connection.execute(
    self.class.arel_table
      .project(*named_function)
      .where(self.class.arel_table[:id].eq(id))
      .to_sql
  ).to_a.first
end

#set_cachedObject (private)

else

  render json: {foo: false}
end

end



1259
1260
1261
# File 'app/models/geographic_item.rb', line 1259

def set_cached
  update_column(:cached_total_area, area)
end

#some_data_is_providedObject (private)



1263
1264
1265
1266
# File 'app/models/geographic_item.rb', line 1263

def some_data_is_provided
  errors.add(:base, 'No shape provided or provided shape is invalid') if
    geography.nil?
end

#st_centroidString

Returns a WKT POINT representing the geometry centroid of the geographic item.

Returns:

  • (String)

    a WKT POINT representing the geometry centroid of the geographic item



944
945
946
947
948
949
950
# File 'app/models/geographic_item.rb', line 944

def st_centroid
  select_from_self(
    self.class.st_as_text_sql(
      self.class.st_centroid_sql(self.class.geography_as_geometry)
    )
  )['st_astext']
end

#st_distance_to_geographic_item(geographic_item) ⇒ Double

Works with changed and non persisted objects

Parameters:

Returns:

  • (Double)

    distance in meters



924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
# File 'app/models/geographic_item.rb', line 924

def st_distance_to_geographic_item(geographic_item)
  if persisted? && !changed?
    a = self.class.select_geography_sql(id)
  else
    a = self.class.st_geography_from_text_sql(geo_object.to_s)
  end

  if geographic_item.persisted? && !geographic_item.changed?
    b = self.class.select_geography_sql(geographic_item.id)
  else
    b = self.class.st_geography_from_text_sql(geographic_item.geo_object.to_s)
  end

  self.class.select_value(
    self.class.st_distance_sql(a, b)
  )
end

#st_is_validObject



1110
1111
1112
1113
1114
1115
1116
# File 'app/models/geographic_item.rb', line 1110

def st_is_valid
  select_from_self(
    self.class.st_is_valid_sql(
      self.class.geography_as_geometry
    )
  )['st_isvalid']
end

#st_is_valid_reasonObject



1118
1119
1120
1121
1122
1123
1124
# File 'app/models/geographic_item.rb', line 1118

def st_is_valid_reason
  select_from_self(
    self.class.st_is_valid_reason_sql(
      self.class.geography_as_geometry
    )
  )['st_isvalidreason']
end

#to_geo_jsonHash

Returns in GeoJSON format.

Returns:

  • (Hash)

    in GeoJSON format



993
994
995
996
997
998
999
# File 'app/models/geographic_item.rb', line 993

def to_geo_json
  JSON.parse(
    select_from_self(
      self.class.st_as_geo_json_sql(self.class.arel_table[:geography])
    )['st_asgeojson']
  )
end

#to_geo_json_featureHash

Returns the shape as a GeoJSON Feature with some item metadata.

Returns:

  • (Hash)

    the shape as a GeoJSON Feature with some item metadata



1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
# File 'app/models/geographic_item.rb', line 1003

def to_geo_json_feature
  {
    'type' => 'Feature',
    'geometry' => to_geo_json,
    'properties' => {
      'geographic_item' => {
        'id' => id
      }
    }
  }
end

#to_wktString

Returns wkt.

Returns:

  • (String)

    wkt



1049
1050
1051
1052
1053
# File 'app/models/geographic_item.rb', line 1049

def to_wkt
  select_from_self(
    self.class.st_as_text_sql(self.class.geography_as_geometry)
  )['st_astext']
end

#within?(target_geo_object) ⇒ Boolean

Parameters:

Returns:

  • (Boolean)


982
983
984
# File 'app/models/geographic_item.rb', line 982

def within?(target_geo_object)
  self.geo_object.within?(target_geo_object)
end