Browse Source

Implement the region with distance for regions.

This fetches the places for a region based on the region
center and a rectangular envelope around it.
feature/region_center
Bèr Kessels 4 months ago
parent
commit
578fc65585

+ 5
- 0
app/events/region_added.rb View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true

require 'event_sourcery/event'

RegionAdded = Class.new(EventSourcery::Event)

+ 1
- 1
app/models/region.rb View File

@@ -11,7 +11,7 @@ module Hours
class Region < PlaceIndex
def initialize(dataset,
paginator = nil, centerpoint = nil, region_slug = '')
@region_slug = region_slug
@region_slug = region_slug || ''
super(dataset, paginator, centerpoint)
end


+ 26
- 27
app/projections/query.rb View File

@@ -9,6 +9,8 @@ module Hours
# Common behaviour for all query objects
class BaseQuery
DEFAULT_DATASET = Hours.projections_database[:query_places]
BUFFERX = 0.005302691970002
BUFFERY = 0.01297116279602
attr_reader :dataset, :page

include Pagy::Backend
@@ -52,42 +54,39 @@ module Hours

# Query handler that builds a list of places in a Region
class RegionQuery < BaseQuery
def handle(region_slug, page = 1)
def handle(slug, page = 1)
@page = page
@region_slug = region_slug
raise ArgumentError if region_slug.nil? || region_slug.empty?

distance = Sequel.lit('ST_Distance(location, ?) as distance', center)
pagy, records = pagy(
dataset.select_append(distance)
.where(region_slug: region_slug)
.order(:distance)
)
Hours::Models::Region.new(records, pagy, center, region_slug)
@region_slug = slug
return Hours::Models::Region.new([], {}, nil, nil) unless region

pagy, records = pagy(dataset.order(distance).where(in_envelope))
Hours::Models::Region.new(records,
pagy,
region[:centerpoint],
region[:name])
end

private

def center
return @center if @center
return unless region_name

centroid = OfflineGeocoder.new.search(
name: region_name,
cc: 'NL'
)
@center = GeoRuby::SimpleFeatures::Point.from_xy(
centroid[:lon], centroid[:lat]
)
def region
raise ArgumentError if @region_slug.nil? || @region_slug.empty?

@region ||= Hours.projections_database[:query_regions]
.first(slug: @region_slug)
end

def region_name
return @region_name if @region_name
def distance
Sequel.lit('location <-> ?', region[:centerpoint])
end

first_place = dataset.select(:address).first(region_slug: @region_slug)
return unless first_place
def in_envelope
Sequel.lit('location && ST_MakeEnvelope(?, ?, ?, ?, 4326)',
*envelope_corners)
end

@region_name = Hours::Models::Place.new(first_place).region
def envelope_corners
cp = region[:centerpoint]
[cp.x + BUFFERX, cp.y + BUFFERY, cp.x - BUFFERX, cp.y - BUFFERY]
end
end
end

+ 3
- 4
app/views/region.erb View File

@@ -68,11 +68,10 @@
<%- @region.each do |place| %>
<a href="/places/<%= place.id %>" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h3 class="mb-1"><%= place.name %></h3>
<small><%= place.id %></small>
<h3 class="mb-1 small"><%= place.name %></h3>
</div>
<p class="mb-1"><%= place.address.street %>, <%= place.address.city %></p>
<p class="mb-1">
<p class="mb-1 small"><%= place.address.street %>, <%= place.address.city %></p>
<p class="mb-1 small">
<%== erb :status_badge_partial, locals: { status: place.status } %>
<%= place.opening_hours %>
</p>

+ 3
- 0
test/fixtures/address_sample.csv View File

@@ -137,3 +137,6 @@ ef89c57948db291d,3,Arsenaalgas,,Nijmegen,,Gelderland,6511PB,SRID=4326;POINT(5.86
847f8daacc86291f,7A,Arsenaalgas,,Nijmegen,,Gelderland,6511PB,SRID=4326;POINT(5.8661005 51.8446562)
a2fc8c0a642409e8,7,Arsenaalgas,,Nijmegen,,Gelderland,6511PB,SRID=4326;POINT(5.8658718 51.8447315)
eaa13cc029788f00,8,Arsenaalgas,,Nijmegen,,Gelderland,6511PE,SRID=4326;POINT(5.865782 51.8447778)
6bb10d7d1ceb5839,8,Oosterseweg,,Zuidwolde,,Groningen,9785AE,SRID=4326;POINT(6.5941314 53.2602521)
8c8ef232d536eb8e,18,Noordwolderweg,,Zuidwolde,,Groningen,9785AS,SRID=4326;POINT(6.5902583 53.2616299)
20f7c079218a7604,107,Hoofdstraat,,Zuidwolde,,Drenthe,7921AG,SRID=4326;POINT(6.4295518 52.6714077)

+ 37
- 0
test/fixtures/input/arnhem_city.json View File

@@ -0,0 +1,37 @@
{
"type": "Feature",
"id": "6394641551",
"geometry": {
"type": "Point",
"coordinates": [
5.9108573,
51.984257
]
},
"properties": {
"capital": "4",
"name": "Arnhem",
"name:ar": "أرنهيم",
"name:de": "Arnheim",
"name:en": "Arnhem",
"name:es": "Arnhem",
"name:fr": "Arnhem",
"name:fy": "Arnhim",
"name:he": "ארנהם",
"name:la": "Arenacum",
"name:lt": "Arnhemas",
"name:nds": "Arnem",
"name:nl": "Arnhem",
"name:ru": "Арнем",
"name:sr": "Арнем",
"name:uk": "Арнем",
"place": "city",
"population": "155694",
"population:date": "2017-01-01",
"population:note": "Inwonertal inclusief Elden en Schaarsbergen",
"source:population": "https://arnhem.buurtmonitor.nl//jive",
"website": "https://www.arnhem.nl/",
"wikidata": "Q1310",
"wikipedia": "nl:Arnhem"
}
}

+ 41
- 0
test/fixtures/input/denbosch_city.json View File

@@ -0,0 +1,41 @@
{
"type": "Feature",
"id": "60400458",
"geometry": {
"type": "Point",
"coordinates": [
5.3031044,
51.6889351
]
},
"properties": {
"alt_name": "Den Bosch",
"capital": "4",
"loc_name": "Den Bosch",
"name": "'s-Hertogenbosch",
"name:ar": "سيرتوخيمبوس",
"name:carnaval": "Oeteldonk",
"name:de": "Herzogenbusch",
"name:es": "Bolduque",
"name:fr": "Bois-le-Duc",
"name:fy": "De Bosk",
"name:id": "Den Bosch",
"name:la": "Silva Ducis",
"name:li": "De Bósj",
"name:lt": "Hertogenbosas",
"name:mk": "Хертогенбос",
"name:nds": "Den Bosch",
"name:ru": "Хертогенбос",
"name:sr": "Хертогенбос",
"name:uk": "Гертогенбос",
"name:zh": "斯海尔托亨博斯",
"place": "city",
"population": "115903",
"population:date": "2017-01-01",
"population:note": "Inwonertal gemeente minus Rosmalen, Nuland en Vinkel.",
"source:population": "https://www.s-hertogenbosch.nl",
"website": "https://www.s-hertogenbosch.nl/",
"wikidata": "Q2766547",
"wikipedia": "nl:'s-Hertogenbosch"
}
}

+ 32
- 0
test/fixtures/input/eindhoven_city.json View File

@@ -0,0 +1,32 @@
{
"type": "Feature",
"id": "42616340",
"geometry": {
"type": "Point",
"coordinates": [
5.478633,
51.4392648
]
},
"properties": {
"name": "Eindhoven",
"name:carnaval": "Lampegat",
"name:el": "Αϊντχόφεν",
"name:he": "איינדהובן",
"name:la": "Endhovia",
"name:lt": "Eindhovenas",
"name:mk": "Ајндховен",
"name:ru": "Эйндховен",
"name:sr": "Ајндховен",
"name:uk": "Ейндговен",
"name:zh": "埃因霍温",
"place": "city",
"population": "226921",
"population:date": "2017-01-01",
"population:note": "Inwonertal inclusief Kerkdorp Acht",
"source:population": "https://eindhoven.incijfers.nl",
"website": "https://www.eindhoven.nl/",
"wikidata": "Q9832",
"wikipedia": "nl:Eindhoven"
}
}

+ 16
- 0
test/fixtures/input/ekoplaza_arnhem.json View File

@@ -0,0 +1,16 @@
{
"type": "Feature",
"id": "3210878424",
"geometry": {
"type": "Point",
"coordinates": [
5.9112061,
51.983185
]
},
"properties": {
"name": "Ekoplaza",
"shop": "supermarket",
"wheelchair": "yes"
}
}

+ 38
- 0
test/fixtures/input/nijmegen_city.json View File

@@ -0,0 +1,38 @@
{
"type": "Feature",
"id": "867975219",
"geometry": {
"type": "Point",
"coordinates": [
5.8634696,
51.8427385
]
},
"properties": {
"name": "Nijmegen",
"name:carnaval": "Knotsenburg",
"name:de": "Nimwegen",
"name:es": "Nimega",
"name:eu": "Nimega",
"name:fr": "Nimègue",
"name:fy": "Nymwegen",
"name:it": "Nimega",
"name:la": "Noviomagus Batavorum",
"name:lt": "Neimegenas",
"name:nds": "Nimwaege",
"name:nl": "Nijmegen",
"name:ru": "Неймеген",
"name:sr": "Најмеген",
"name:uk": "Неймеген",
"name:zh": "奈梅亨",
"old_name:en": "Nimeguen",
"place": "city",
"population": "163487",
"population:date": "2017-01-01",
"population:note": "Inwonertal gemeente minus Lent.",
"source:population": "https://nijmegen.buurtmonitor.nl/jive",
"website": "https://nijmegen.nl/",
"wikidata": "Q47887",
"wikipedia": "nl:Nijmegen"
}
}

+ 23
- 0
test/fixtures/input/zuidwolde_bb.json View File

@@ -0,0 +1,23 @@
{
"type": "Feature",
"id": "2754051107",
"geometry": {
"type": "Point",
"coordinates": [
6.5941314,
53.2602521
]
},
"properties": {
"addr:city": "Zuidwolde",
"addr:housenumber": "8",
"addr:postcode": "9785AE",
"addr:street": "Oosterseweg",
"guest_house": "bed_and_breakfast",
"name": "Bed en brood 'Irene'",
"phone": "+31 6 51961751",
"source": "BAG",
"source:date": "2014-03-24",
"tourism": "guest_house"
}
}

+ 25
- 0
test/fixtures/input/zuidwolde_dr_region.json View File

@@ -0,0 +1,25 @@
{
"type": "Feature",
"id": "3523151185",
"geometry": {
"type": "Point",
"coordinates": [
6.4317343,
52.6749542
]
},
"properties": {
"is_in:continent": "Europe",
"is_in:country": "The Netherlands",
"is_in:country_code": "NL",
"name": "Zuidwolde",
"name:ru": "Зёйдволде",
"place": "village",
"population": "6240",
"population:date": "2017-01-01",
"postal_code": "7921",
"source:population": "CBS Wijk 2017",
"wikidata": "Q2754055",
"wikipedia": "nl:Zuidwolde (Drenthe)"
}
}

+ 16
- 0
test/fixtures/input/zuidwolde_fastfood.json View File

@@ -0,0 +1,16 @@
{
"type": "Feature",
"id": "2311319392",
"geometry": {
"type": "Point",
"coordinates": [
6.5921197,
53.260827
]
},
"properties": {
"amenity": "fast_food",
"name": "Moeke Vaatstra",
"wheelchair": "limited"
}
}

+ 21
- 0
test/fixtures/input/zuidwolde_gn_region.json View File

@@ -0,0 +1,21 @@
{
"type": "Feature",
"id": "48298240",
"geometry": {
"type": "Point",
"coordinates": [
6.5918683,
53.2607348
]
},
"properties": {
"is_in": "NL",
"name": "Zuidwolde",
"name:ru": "Зёйдволде",
"place": "village",
"population": "1010",
"population:date": "2017-01-01",
"postal_code": "9785",
"source:population": "CBS Buurt 2017"
}
}

+ 16
- 0
test/fixtures/input/zuidwolde_grillroom.json View File

@@ -0,0 +1,16 @@
{
"type": "Feature",
"id": "2599631016",
"geometry": {
"type": "Point",
"coordinates": [
6.4295856,
52.6714759
]
},
"properties": {
"amenity": "restaurant",
"cuisine": "kebab",
"name": "Grillroom Yusuf"
}
}

+ 3
- 0
test/integration/api/add_places_test.rb View File

@@ -1,14 +1,17 @@
# frozen_string_literal: true

require 'test_helper'
require_relative Hours.base_path.join('test/support/workflows/add_region.rb')

describe 'add place' do
describe 'POST /places' do
let(:lat) { 51.84 }
let(:lon) { 5.86 }
let(:payload) { json_fixtures('input/hm_broerstraat.json') }
let(:regions) { [json_fixtures('input/nijmegen_city.json')] }

before do
Workflows::AddRegion.new(self).call
header('Accept', 'application/json')
end


+ 14
- 3
test/integration/cli/sink_test.rb View File

@@ -4,8 +4,8 @@ require 'test_helper'
require 'open3'

describe 'sink' do
describe 'pipe to sink' do
let(:payload) { json_fixtures('input/hm_broerstraat.json') }
describe 'pipe POI to sink' do
let(:input_file) { fixtures('input/hm_broerstraat.json') }

it 'creates an event' do
run_sink_pipe
@@ -20,9 +20,20 @@ describe 'sink' do
end
end

describe 'pipe Place to sink' do
let(:input_file) { fixtures('input/nijmegen_city.json') }

it 'creates an event' do
run_sink_pipe
assert_kind_of(RegionAdded, last_event)
refute_nil(last_event.aggregate_id)
assert_equal(last_event.body['properties']['name'], 'Nijmegen')
end
end

def run_sink_pipe
status_list = Open3.pipeline(
['cat', fixtures('input/hm_broerstraat.json')],
['cat', input_file],
[{ 'LOG_LEVEL' => '1' }, './bin/sink']
)
status_list.each { |status| assert status.success? }

+ 66
- 5
test/integration/web/view_regions_test.rb View File

@@ -3,12 +3,16 @@
require 'test_helper'

require_relative Hours.base_path.join('test/support/workflows/add_place.rb')
require_relative Hours.base_path.join('test/support/workflows/add_region.rb')

describe 'web views regions' do
include WebTestHelpers
include Workflows::AddPlace

before { Workflows::AddRegion.new(self).call }

describe 'GET /in/nijmegen' do
let(:regions) { [json_fixtures('input/nijmegen_city.json')] }
let(:input) do
[
json_fixtures('input/hm_burchtstraat.json'),
@@ -30,7 +34,8 @@ describe 'web views regions' do

describe 'with 21 items in nijmegen' do
# Reverse to avoid false positives because of insert order
let(:input) { list_of_geojson(21).reverse }
let(:nijmegen) { geojson_fixtures('input/nijmegen_city.json') }
let(:input) { list_of_geojson(21, nijmegen.geometry).reverse }

it 'pages the index' do
page.assert_current_path 'http://www.example.com/in/nijmegen'
@@ -55,24 +60,79 @@ describe 'web views regions' do
# This tests that we actually use the centroid.
# Also tests against "real world" distances.
describe 'GET /in/arnhem' do
let(:regions) { [json_fixtures('input/arnhem_city.json')] }
let(:input) do
[
json_fixtures('input/hm_arnhem.json'),
json_fixtures('input/aldi_arnhem.json')
json_fixtures('input/ekoplaza_arnhem.json')
]
end

it 'sorts them from the centroid of Arnhem' do
visit '/in/arnhem'

page.assert_no_text 'No places found in Arnhem'
items = page.find_all('a.list-group-item')
# Aldi is furthest from "Arnhem Center", but closest to "Nijmegen"
items[0].assert_text 'H&M'
items[1].assert_text 'Aldi'
# Ecoplaza is nearest to centroid
items[0].assert_text 'Ekoplaza'
items[1].assert_text 'H&M'
end
end

describe 'GET /in/zuidwolde supports tiny hamlets' do
let(:regions) { [json_fixtures('input/zuidwolde_gn_region.json')] }
let(:input) { [json_fixtures('input/zuidwolde_fastfood.json')] }

it 'supports tiny hamlets' do
visit '/in/zuidwolde'
page.assert_text 'Moeke Vaatstra'
end
end

##
# As a user living in a city that shares a name with other cities
# When I look up my region
# Then I get places only related to my region.
describe 'GET /in/zuidwolde with duplicate names' do
let(:regions) do
[
json_fixtures('input/zuidwolde_gn_region.json'),
json_fixtures('input/zuidwolde_dr_region.json')
]
end
# All places are in villages called "Zuidwolde".
# Both villages "Zuidwolde" are in separate provinces
# One village (in Groningen) has two places,
# the other (in Drenthe) only one. Groningen one is "more popular".
let(:input) do
[
json_fixtures('input/zuidwolde_bb.json'),
json_fixtures('input/zuidwolde_fastfood.json'),
json_fixtures('input/zuidwolde_grillroom.json')
]
end

it 'has a region zuidwolde' do
visit '/in/zuidwolde'

page.assert_text 'Moeke Vaatstra'
page.assert_text "Bed en brood 'Irene'"

page.assert_no_text 'Grillroom Yusuf'
end

it 'has a region zuidwolde-1' do
visit '/in/zuidwolde-1'

page.assert_text 'Grillroom Yusuf'

page.assert_no_text 'Moeke Vaatstra'
page.assert_no_text "Bed en brood 'Irene'"
end
end

describe 'region with complex characters' do
let(:regions) { [json_fixtures('input/denbosch_city.json')] }
let(:input) { [json_fixtures('input/jan_de_groot_shertogenbosch.json')] }

it 'Renders region name with UTF8 but slug is normalized' do
@@ -83,6 +143,7 @@ describe 'web views regions' do
end

describe 'GET /in/eindhoven empty place' do
let(:regions) { [json_fixtures('input/eindhoven_city.json')] }
let(:input) { [json_fixtures('input/hm_arnhem.json')] }

it 'shows empty text' do

+ 26
- 18
test/projections/query_test.rb View File

@@ -1,7 +1,10 @@
# frozen_string_literal: true

require 'test_helper'
require 'geo_ruby/geojson'

require_relative Hours.base_path.join('test/support/workflows/add_place.rb')
require_relative Hours.base_path.join('test/support/workflows/add_region.rb')

describe Hours::Projections::PlacesQuery do
# TODO: we might not need to run through the entire event process here.
@@ -57,9 +60,20 @@ describe Hours::Projections::RegionQuery do
include Workflows::AddPlace
let(:input) { [json_fixtures('input/hm_burchtstraat.json')] }

let(:regions) do
[
json_fixtures('input/nijmegen_city.json'),
json_fixtures('input/arnhem_city.json')
]
end

let(:nijmegen) { geojson_fixtures('input/nijmegen_city.json') }
let(:arnhem) { geojson_fixtures('input/arnhem_city.json') }

subject do
Hours::Projections::RegionQuery.build
end
before { Workflows::AddRegion.new(self).call }

it '#handle fetches a Region' do
assert_kind_of Hours::Models::Region, subject.handle('nijmegen')
@@ -70,17 +84,18 @@ describe Hours::Projections::RegionQuery do
assert subject.dataset.insert(
id: SecureRandom.uuid,
place_id: SecureRandom.hex,
region_slug: 'nijmegen'
region_slug: 'TODO-REMOVE',
location: nijmegen.geometry
)
end
assert subject.dataset.insert(
id: SecureRandom.uuid,
place_id: SecureRandom.hex,
region_slug: 'arnhem'
region_slug: 'TODO-REMOVE',
location: arnhem.geometry
)

assert_equal 22, subject.dataset.count
assert_equal 21, subject.dataset.where(region_slug: 'nijmegen').count

result = subject.handle('nijmegen', 1)
assert_equal 20, result.count
@@ -88,30 +103,23 @@ describe Hours::Projections::RegionQuery do
end

it 'sorts the results by distance from region center' do
nijmegen = OfflineGeocoder.new.search(name: 'Nijmegen', cc: 'NL')
center = GeoRuby::SimpleFeatures::Point.from_xy(
nijmegen[:lon], nijmegen[:lat]
)
# Counting down to avoid false positive because of insert order
3.downto(0).each do |i|
location = GeoRuby::SimpleFeatures::Point.from_xy(
center.x + i.to_f / 10,
center.y
)
location = nijmegen.geometry.dup
Array.new(3) do
location.x += 0.00001
assert subject.dataset.insert(
id: SecureRandom.uuid,
place_id: SecureRandom.hex,
region_slug: 'nijmegen',
location: location,
address: Sequel.hstore(city: 'Nijmegen')
region_slug: 'TODO-REMOVE',
location: location
)
end
result = subject.handle('nijmegen')
assert_equal center, result.centerpoint
assert_equal nijmegen.geometry, result.centerpoint

places = result.to_a
distance0 = center.euclidian_distance(places[0].location)
distance1 = center.euclidian_distance(places[1].location)
distance0 = nijmegen.geometry.euclidian_distance(places[0].location)
distance1 = nijmegen.geometry.euclidian_distance(places[1].location)
assert distance1 > distance0
end


+ 6
- 0
test/support/data_helpers.rb View File

@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'geo_ruby/geojson'

##
# Helpers for test-data
module DataHelpers
@@ -17,4 +19,8 @@ module DataHelpers
def json_fixtures(file)
JSON.parse(File.read(fixtures(file)), symbolize_names: true)
end

def geojson_fixtures(file)
GeoRuby::SimpleFeatures::Geometry.from_geojson(File.read(fixtures(file)))
end
end

+ 21
- 7
test/support/workflows/add_place.rb View File

@@ -12,20 +12,24 @@ module Workflows

protected

CENTROID = [51.845259, 5.864579].freeze

def input
[]
end

def list_of_geojson(amount)
def default_centroid
GeoRuby::SimpleFeatures::Point.from_coordinates(0, 0)
end

def list_of_geojson(amount, centroid = default_centroid)
coords = centroid.dup

Array.new(amount) do |i|
coords = [CENTROID[1], (CENTROID[0] + i.to_f / 100)]
coords.x += diff_x(amount)
coords.y += diff_y(amount)
{ type: 'Feature',
geometry: { type: 'Point', coordinates: coords },
geometry: { type: 'Point', coordinates: coords.to_xy },
properties: {
name: "place ##{i} #{Digest::SHA1.hexdigest(i.to_s)}",
'addr:city': 'Nijmegen'
name: "place ##{i} #{Digest::SHA1.hexdigest(i.to_s)}"
} }
end
end
@@ -65,5 +69,15 @@ module Workflows
def esps
@esps = [Hours::Projections::Places::Projector.new]
end

def diff_x(amount)
# substract 0.001 to avoid rounding errors tipping points over the
# envelope border
Hours::Projections::BaseQuery::BUFFERX / (amount - 0.001)
end

def diff_y(amount)
Hours::Projections::BaseQuery::BUFFERY / (amount - 0.001)
end
end
end

+ 51
- 0
test/support/workflows/add_region.rb View File

@@ -0,0 +1,51 @@
# frozen_string_literal: true

module Workflows
##
# Workflow to add a region (city) to the projection
#
# TODO: DRY with AddPlace. Lots of similarities.
class AddRegion
attr_reader :test_obj

def initialize(test_obj)
@test_obj = test_obj
end

def call
import_geojson
end

protected

def regions
test_obj.class.method_defined?(:regions) ? test_obj.regions : []
end

def import_geojson
create_events
process_events
end

def create_events
regions.each do |location_geojson|
command = Hours::AddRegionCommand.build(location_geojson)
Hours::CommandHandler.new.handle(command)
end
end

def process_events
Hours.event_source.each_by_range(0, 1) do |event|
esps.each do |ep|
ep.process(event)
end
end
end

private

def esps
@esps ||= [Hours::Projections::Regions::Projector.new]
end
end
end

Loading…
Cancel
Save