Skip to content

Commit c19c1b7

Browse files
committed
issue filtering by bounding box and distance #51
- to filter by bbounding box, use the bbox URL param with a value like '123.193645|9.256139|123.331833|9.364216' (lng1,lat1,lng2,lat2) - to filter by distance, use the distance URL param with a value like '<=1000|123.2696|9.3050' where 1000 is the distance in meters and the following two values are lng,lat. - when filtering by distance, you can also use sort=distance or sort=distance:desc to use it for ordering - at this time this works only through the API, for the Web UI some Javascript is needed to transfer the current map center / extend into the filter form fields.
1 parent 2f87c7a commit c19c1b7

File tree

5 files changed

+333
-9
lines changed

5 files changed

+333
-9
lines changed

app/views/issues/index.api.rsb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ api.array :issues, api_meta(:total_count => @issue_count, :offset => @offset, :l
2626
api.geojson ""
2727
end
2828

29+
if issue.distance
30+
api.distance issue.distance
31+
end
32+
2933
render_api_custom_values issue.visible_custom_field_values, api
3034

3135
api.created_on issue.created_on

config/locales/en.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ en:
2626
gtt_text_settings_geometry_example: "2747491302261fbc47967ba62621af22"
2727

2828
label_gtt: "GTT"
29-
label_gtt_bounding_box_filter: Location
29+
label_gtt_bbox_filter: Location
30+
label_gtt_distance: Distance
3031
label_gtt_tile_source: Tile Source
3132
label_gtt_tile_source_new: New Tile Source
3233
label_gtt_tile_source_plural: Tile Sources

lib/redmine_gtt/patches/issue_patch.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ def self.apply
88
Issue.prepend self
99
Issue.prepend GeojsonAttribute
1010
Issue.class_eval do
11+
attr_reader :distance
1112
safe_attributes "geojson",
1213
if: ->(issue, user){
1314
perm = issue.new_record? ? :add_issues : :edit_issues

lib/redmine_gtt/patches/issue_query_patch.rb

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,147 @@ module IssueQueryPatch
55
def self.apply
66
unless IssueQuery < self
77
IssueQuery.prepend self
8-
IssueQuery.add_available_column QueryColumn.new(:geom,
9-
caption: :field_geom)
108
end
119
end
1210

1311
def self.prepended(base)
1412
end
1513

14+
def issues(*_)
15+
super.tap do |issues|
16+
if center = find_center_point
17+
load_distances(issues, center)
18+
end
19+
end
20+
rescue ::ActiveRecord::StatementInvalid => e
21+
raise ::Query::StatementInvalid.new(e.message)
22+
end
23+
24+
def available_columns
25+
return @available_columns if @available_columns
26+
27+
super.tap do |columns|
28+
29+
if project.nil? or project.module_enabled?('gtt')
30+
columns << QueryColumn.new(:geom,
31+
caption: :field_geom
32+
)
33+
columns << QueryColumn.new(:distance,
34+
caption: :label_gtt_distance,
35+
sortable: lambda{
36+
lng, lat = find_center_point
37+
distance_query lng, lat
38+
}
39+
)
40+
end
41+
42+
end
43+
end
44+
45+
1646
# weiter: use 'On map' option tag to hold current map extent
1747
# - wenn filter hinzugefügt befüllen (dom hook oder so?)
1848
# - on map move / zoom updaten (app.map.js)
1949
def initialize_available_filters()
2050
super
2151
if project and project.module_enabled?('gtt')
2252
add_available_filter(
23-
'location_filter',
24-
name: l(:label_gtt_location_filter),
53+
'bbox',
54+
name: l(:label_gtt_bbox_filter),
2555
type: :list,
2656
values: ['On map']
2757
)
58+
add_available_filter(
59+
'distance',
60+
name: l(:label_gtt_distance),
61+
type: :float,
62+
)
2863
end
2964
end
3065

31-
def available_columns
32-
super.tap do |columns|
33-
if project and !project.module_enabled?('gtt')
34-
columns.reject!{|c| c.name == :geom}
66+
67+
def sql_for_distance_field(field, operator, value)
68+
case operator
69+
when '*'
70+
"#{Issue.table_name}.geom IS NOT NULL"
71+
when '!*'
72+
"#{Issue.table_name}.geom IS NULL"
73+
else
74+
# value has to be ['meters_min', 'lng', 'lat']
75+
# or ['meters_min', 'meters_max', 'lng', 'lat'] if op == '><'
76+
lng, lat = value.last(2).map(&:to_f)
77+
distance = value.first.to_i
78+
sql = "ST_Distance_Sphere(#{Issue.table_name}.geom, ST_GeomFromText('POINT(#{lng} #{lat})',4326))"
79+
if operator == '><'
80+
distance_max = value[1].to_i
81+
sql << " BETWEEN #{distance} AND #{distance_max}"
82+
else
83+
sql << " #{operator} #{distance}"
3584
end
3685
end
3786
end
87+
88+
89+
def sql_for_bbox_field(field, operator, value)
90+
not_in = "not " if operator == '!'
91+
92+
# value should be ['lng1|lat1|lng2|lat2'] or 'lng1|lat1|lng2|lat2' or
93+
# ['lng1','lat1','lng2','lat2']
94+
if value.is_a?(Array) && value.size == 1
95+
value = value.first
96+
end
97+
98+
if value.is_a?(String)
99+
value = value.split('|')
100+
end
101+
102+
# sanitize the coordinate values:
103+
lng1,lat1,lng2,lat2 = value.map(&:to_f)
104+
105+
# TODO
106+
# First I tried this, but it continued to complain about mixing
107+
# different SRIDs:
108+
# coordinates = [
109+
# [lng1,lat1], [lng2,lat1], [lng2,lat2], [lng1,lat2]
110+
# ].map{|a| a.join ' '}.join(',')
111+
# box = "ST_Polygon(ST_GeomFromText('LINESTRING(#{coordinates})'), 4326)"
112+
# "#{not_in}ST_Contains(#{box}, ST_SetSRID(#{db_table}.geom, 4326))"
113+
114+
# So instead, I came up with this:
115+
"#{not_in}ST_MakeEnvelope(#{lng1},#{lat1},#{lng2},#{lat2}, 4326) ~ #{Issue.table_name}.geom"
116+
# I am not sure about the implications of using ~ instead of one of
117+
# the ST_ functions. Appears that it is a PostgreSQL geometric
118+
# operator, leading to casting the PostGIS values to a PostgreSQL
119+
# geometric type. Fact is, this statement does not seem to care
120+
# about the SRID (can even leave it out of the MakeEnvelope call).
121+
# Doesn't feel right but works for my simple test case and is
122+
# probably good enough for this simple case (bbox is a rectangle and
123+
# other geometry is a point). We should check if indizes are actually
124+
# used though.
125+
end
126+
127+
private
128+
129+
def find_center_point
130+
if v = values_for('distance') and v.size > 2
131+
v.last(2).map(&:to_f)
132+
end
133+
end
134+
135+
def distance_query(lng, lat)
136+
"ST_Distance_Sphere(#{Issue.table_name}.geom, ST_GeomFromText('POINT(#{lng} #{lat})',4326))"
137+
end
138+
139+
def load_distances(issues, center_point)
140+
lng, lat = center_point
141+
distances = Hash[
142+
Issue.
143+
where(id: issues.map(&:id)).
144+
pluck(:id, "#{distance_query(lng, lat)}")
145+
]
146+
issues.each{|i| i.instance_variable_set :@distance, distances[i.id]}
147+
end
148+
38149
end
39150
end
40151
end
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
require_relative '../test_helper'
2+
3+
class IssueFilterApiTest < Redmine::ApiTest::Base
4+
fixtures :projects,
5+
:users,
6+
:roles,
7+
:members,
8+
:member_roles,
9+
:issues,
10+
:issue_statuses,
11+
:issue_relations,
12+
:versions,
13+
:trackers,
14+
:projects_trackers,
15+
:issue_categories,
16+
:enabled_modules,
17+
:enumerations,
18+
:attachments,
19+
:workflows,
20+
:custom_fields,
21+
:custom_values,
22+
:custom_fields_projects,
23+
:custom_fields_trackers,
24+
:time_entries,
25+
:journals,
26+
:journal_details,
27+
:queries,
28+
:attachments
29+
30+
setup do
31+
@project = Project.find 'ecookbook'
32+
@project.enabled_modules.create name: 'gtt'
33+
end
34+
35+
POINT_OUT = {
36+
'type' => 'Feature',
37+
'geometry' => {
38+
'type' => 'Point',
39+
'coordinates' => [123.324966,9.425016]
40+
}
41+
}
42+
43+
POINT_IN = {
44+
'type' => 'Feature',
45+
'geometry' => {
46+
'type' => 'Point',
47+
'coordinates' => [123.269691,9.305099]
48+
}
49+
}
50+
51+
# x1,y1,x2,y2 (Lng1,Lat1,...)
52+
BBOX = '123.193645|9.256139|123.331833|9.364216'
53+
54+
55+
test 'should filter by distance' do
56+
57+
get '/projects/ecookbook/issues.json', params: {
58+
status_id: 'o',
59+
}
60+
61+
assert_response :success
62+
assert data = JSON.parse(response.body)
63+
assert_equal 6, data['issues'].size
64+
65+
issue_in = @project.issues.find 1
66+
issue_in.update_attribute :geojson, POINT_IN.to_json
67+
68+
issue_out = @project.issues.find 2
69+
issue_out.update_attribute :geojson, POINT_OUT.to_json
70+
71+
# find everyting inside 1km radius
72+
# using the 'short' parameter format
73+
74+
get '/projects/ecookbook/issues.json', params: {
75+
status_id: 'o',
76+
distance: '<=1000|123.2696|9.3050',
77+
}
78+
assert_response :success
79+
assert data = JSON.parse(response.body)
80+
assert_equal 1, data['issues'].size
81+
assert_equal issue_in.id, data['issues'][0]['id']
82+
assert dist = data['issues'][0]['distance'].to_i
83+
assert dist > 0
84+
assert dist < 1000
85+
86+
87+
# filter and sort by distance
88+
#
89+
get '/projects/ecookbook/issues.json', params: {
90+
status_id: 'o',
91+
distance: '<=100000|123.2696|9.3050',
92+
sort: 'distance'
93+
}
94+
assert_response :success
95+
assert data = JSON.parse(response.body)
96+
assert_equal 2, data['issues'].size
97+
assert_equal issue_in.id, data['issues'][0]['id']
98+
assert_equal issue_out.id, data['issues'][1]['id']
99+
100+
get '/projects/ecookbook/issues.json', params: {
101+
status_id: 'o',
102+
distance: '<=100000|123.2696|9.3050',
103+
sort: 'distance:desc'
104+
}
105+
assert_response :success
106+
assert data = JSON.parse(response.body)
107+
assert_equal 2, data['issues'].size
108+
assert_equal issue_out.id, data['issues'][0]['id']
109+
assert_equal issue_in.id, data['issues'][1]['id']
110+
111+
112+
# find everyting outside 1km radius
113+
# this is using the url params as they come from the web ui just ot make
114+
# sure this works as well
115+
116+
get '/projects/ecookbook/issues.json', params: {
117+
set_filter: 1,
118+
f: %w(status_id distance),
119+
op: { status_id: 'o', distance: '>=' },
120+
v: { distance: ['1000', '123.2696', '9.3050'] }
121+
}
122+
assert_response :success
123+
assert data = JSON.parse(response.body)
124+
assert_equal 1, data['issues'].size
125+
assert_equal issue_out.id, data['issues'][0]['id']
126+
127+
128+
# find everyting on the 1km radius
129+
# more of a theoretical use case...
130+
get '/projects/ecookbook/issues.json', params: {
131+
status_id: 'o',
132+
distance: '=1000|123.2696|9.3050'
133+
}
134+
assert_response :success
135+
assert data = JSON.parse(response.body)
136+
assert_equal 0, data['issues'].size
137+
138+
# find everyting with a distance between 10 and 1000m
139+
get '/projects/ecookbook/issues.json', params: {
140+
status_id: 'o',
141+
distance: '><10|1000|123.2696|9.3050'
142+
}
143+
assert_response :success
144+
assert data = JSON.parse(response.body)
145+
assert_equal 1, data['issues'].size
146+
assert_equal issue_in.id, data['issues'][0]['id']
147+
148+
# find everyting having any distance (finds anything with a geometry set)
149+
get '/projects/ecookbook/issues.json', params: {
150+
status_id: 'o', distance: '*'
151+
}
152+
assert_response :success
153+
assert data = JSON.parse(response.body)
154+
assert_equal 2, data['issues'].size
155+
156+
# find everyting having no distance (finds anything with no geometry set)
157+
get '/projects/ecookbook/issues.json', params: {
158+
status_id: 'o', distance: '!*'
159+
}
160+
assert_response :success
161+
assert data = JSON.parse(response.body)
162+
assert_equal 4, data['issues'].size
163+
end
164+
165+
166+
test 'should filter by bounding box' do
167+
168+
get '/projects/ecookbook/issues.json', params: { status_id: 'o' }
169+
170+
assert_response :success
171+
assert data = JSON.parse(response.body)
172+
assert_equal 6, data['issues'].size
173+
174+
issue_in = @project.issues.find 1
175+
issue_in.update_attribute :geojson, POINT_IN.to_json
176+
177+
issue_out = @project.issues.find 2
178+
issue_out.update_attribute :geojson, POINT_OUT.to_json
179+
180+
# find everyting inside the given box
181+
# using the shorter API parameter format
182+
183+
get '/projects/ecookbook/issues.json', params: {
184+
status_id: 'o', bbox: "=#{BBOX}"
185+
}
186+
assert_response :success
187+
assert data = JSON.parse(response.body)
188+
assert_equal 1, data['issues'].size
189+
assert_equal issue_in.id, data['issues'][0]['id']
190+
191+
192+
# find everyting *outside* the given box
193+
# using the parameter format used by the web UI
194+
195+
get '/projects/ecookbook/issues.json', params: {
196+
set_filter: 1,
197+
f: %w(status_id bbox),
198+
op: { status_id: 'o', bbox: '!' },
199+
v: { bbox: [BBOX] }
200+
}
201+
assert_response :success
202+
assert data = JSON.parse(response.body)
203+
assert_equal 1, data['issues'].size
204+
assert_equal issue_out.id, data['issues'][0]['id']
205+
end
206+
207+
end

0 commit comments

Comments
 (0)