From 0e7cf89492785da927db0da336fec0c835ffb2bb Mon Sep 17 00:00:00 2001 From: Thomas Citharel <tcit@tcit.fr> Date: Wed, 6 Nov 2019 12:51:17 +0100 Subject: [PATCH 1/2] Remove floor Signed-off-by: Thomas Citharel <tcit@tcit.fr> --- js/src/graphql/address.ts | 1 - js/src/graphql/event.ts | 1 - js/src/types/address.model.ts | 2 -- js/src/views/Event/Event.vue | 2 +- lib/mobilizon/addresses/address.ex | 3 --- lib/mobilizon_web/schema/address.ex | 2 -- lib/service/geospatial/addok.ex | 1 - lib/service/geospatial/google_maps.ex | 1 - lib/service/geospatial/map_quest.ex | 1 - lib/service/geospatial/nominatim.ex | 1 - lib/service/geospatial/photon.ex | 1 - ...20191106114524_remove_floor_from_addresses.exs | 15 +++++++++++++++ schema.graphql | 8 +------- test/mobilizon/addresses/addresses_test.exs | 5 ----- test/support/factory.ex | 1 - 15 files changed, 17 insertions(+), 28 deletions(-) create mode 100644 priv/repo/migrations/20191106114524_remove_floor_from_addresses.exs diff --git a/js/src/graphql/address.ts b/js/src/graphql/address.ts index 3035d9ad8..b45790051 100644 --- a/js/src/graphql/address.ts +++ b/js/src/graphql/address.ts @@ -8,7 +8,6 @@ export const ADDRESS = gql` id, description, geom, - floor, street, locality, postalCode, diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts index d4620d5ed..a26250b19 100644 --- a/js/src/graphql/event.ts +++ b/js/src/graphql/event.ts @@ -18,7 +18,6 @@ const participantQuery = ` const physicalAddressQuery = ` description, - floor, street, locality, postalCode, diff --git a/js/src/types/address.model.ts b/js/src/types/address.model.ts index b26bbfd38..8db566691 100644 --- a/js/src/types/address.model.ts +++ b/js/src/types/address.model.ts @@ -1,7 +1,6 @@ export interface IAddress { id?: number; description: string; - floor: string; street: string; locality: string; postalCode: string; @@ -15,7 +14,6 @@ export interface IAddress { export class Address implements IAddress { country: string = ''; description: string = ''; - floor: string = ''; locality: string = ''; postalCode: string = ''; region: string = ''; diff --git a/js/src/views/Event/Event.vue b/js/src/views/Event/Event.vue index f96290106..465b3d8ec 100644 --- a/js/src/views/Event/Event.vue +++ b/js/src/views/Event/Event.vue @@ -116,7 +116,7 @@ import {ParticipantRole} from "@/types/event.model"; <div class="address" v-if="event.physicalAddress"> <address> <span class="addressDescription" :title="event.physicalAddress.description">{{ event.physicalAddress.description }}</span> - <span>{{ event.physicalAddress.floor }} {{ event.physicalAddress.street }}</span> + <span>{{ event.physicalAddress.street }}</span> <span>{{ event.physicalAddress.postalCode }} {{ event.physicalAddress.locality }}</span> </address> <span class="map-show-button" @click="showMap = !showMap" v-if="event.physicalAddress && event.physicalAddress.geom"> diff --git a/lib/mobilizon/addresses/address.ex b/lib/mobilizon/addresses/address.ex index c7c6453e1..0b2de0930 100644 --- a/lib/mobilizon/addresses/address.ex +++ b/lib/mobilizon/addresses/address.ex @@ -14,7 +14,6 @@ defmodule Mobilizon.Addresses.Address do locality: String.t(), region: String.t(), description: String.t(), - floor: String.t(), geom: Geo.PostGIS.Geometry.t(), postal_code: String.t(), street: String.t(), @@ -26,7 +25,6 @@ defmodule Mobilizon.Addresses.Address do @required_attrs [:url] @optional_attrs [ :description, - :floor, :geom, :country, :locality, @@ -42,7 +40,6 @@ defmodule Mobilizon.Addresses.Address do field(:locality, :string) field(:region, :string) field(:description, :string) - field(:floor, :string) field(:geom, Geo.PostGIS.Geometry) field(:postal_code, :string) field(:street, :string) diff --git a/lib/mobilizon_web/schema/address.ex b/lib/mobilizon_web/schema/address.ex index 9a9006f83..da2dbc5ca 100644 --- a/lib/mobilizon_web/schema/address.ex +++ b/lib/mobilizon_web/schema/address.ex @@ -7,7 +7,6 @@ defmodule MobilizonWeb.Schema.AddressType do object :address do field(:geom, :point, description: "The geocoordinates for the point where this address is") - field(:floor, :string, description: "The floor this event is at") field(:street, :string, description: "The address's street name (with number)") field(:locality, :string, description: "The address's locality") field(:postal_code, :string) @@ -32,7 +31,6 @@ defmodule MobilizonWeb.Schema.AddressType do input_object :address_input do # Either a full picture object field(:geom, :point, description: "The geocoordinates for the point where this address is") - field(:floor, :string, description: "The floor this event is at") field(:street, :string, description: "The address's street name (with number)") field(:locality, :string, description: "The address's locality") field(:postal_code, :string) diff --git a/lib/service/geospatial/addok.ex b/lib/service/geospatial/addok.ex index 8dd88d52b..737b233dc 100644 --- a/lib/service/geospatial/addok.ex +++ b/lib/service/geospatial/addok.ex @@ -74,7 +74,6 @@ defmodule Mobilizon.Service.Geospatial.Addok do locality: Map.get(properties, "city"), region: Map.get(properties, "state"), description: Map.get(properties, "name") || street_address(properties), - floor: Map.get(properties, "floor"), geom: geometry |> Map.get("coordinates") |> Provider.coordinates(), postal_code: Map.get(properties, "postcode"), street: properties |> street_address() diff --git a/lib/service/geospatial/google_maps.ex b/lib/service/geospatial/google_maps.ex index 72054c095..1b2960d52 100644 --- a/lib/service/geospatial/google_maps.ex +++ b/lib/service/geospatial/google_maps.ex @@ -127,7 +127,6 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do locality: Map.get(components, "locality"), region: Map.get(components, "administrative_area_level_1"), description: description, - floor: nil, geom: [lon, lat] |> Provider.coordinates(), postal_code: Map.get(components, "postal_code"), street: street_address(components), diff --git a/lib/service/geospatial/map_quest.ex b/lib/service/geospatial/map_quest.ex index 084836a03..5f567224d 100644 --- a/lib/service/geospatial/map_quest.ex +++ b/lib/service/geospatial/map_quest.ex @@ -115,7 +115,6 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do locality: Map.get(address, "adminArea5"), region: Map.get(address, "adminArea3"), description: Map.get(address, "street"), - floor: Map.get(address, "floor"), geom: [lng, lat] |> Provider.coordinates(), postal_code: Map.get(address, "postalCode"), street: Map.get(address, "street") diff --git a/lib/service/geospatial/nominatim.ex b/lib/service/geospatial/nominatim.ex index c00e0e936..3f4a6b9c9 100644 --- a/lib/service/geospatial/nominatim.ex +++ b/lib/service/geospatial/nominatim.ex @@ -78,7 +78,6 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do locality: Map.get(address, "city"), region: Map.get(address, "state"), description: description(body), - floor: Map.get(address, "floor"), geom: [Map.get(body, "lon"), Map.get(body, "lat")] |> Provider.coordinates(), postal_code: Map.get(address, "postcode"), street: street_address(address), diff --git a/lib/service/geospatial/photon.ex b/lib/service/geospatial/photon.ex index df954305c..a42971c8d 100644 --- a/lib/service/geospatial/photon.ex +++ b/lib/service/geospatial/photon.ex @@ -76,7 +76,6 @@ defmodule Mobilizon.Service.Geospatial.Photon do locality: Map.get(properties, "city"), region: Map.get(properties, "state"), description: Map.get(properties, "name") || street_address(properties), - floor: Map.get(properties, "floor"), geom: geometry |> Map.get("coordinates") |> Provider.coordinates(), postal_code: Map.get(properties, "postcode"), street: properties |> street_address() diff --git a/priv/repo/migrations/20191106114524_remove_floor_from_addresses.exs b/priv/repo/migrations/20191106114524_remove_floor_from_addresses.exs new file mode 100644 index 000000000..69112fc18 --- /dev/null +++ b/priv/repo/migrations/20191106114524_remove_floor_from_addresses.exs @@ -0,0 +1,15 @@ +defmodule Mobilizon.Storage.Repo.Migrations.RemoveFloorFromAddresses do + use Ecto.Migration + + def up do + alter table(:addresses) do + remove(:floor) + end + end + + def down do + alter table(:addresses) do + add(:floor, :string) + end + end +end diff --git a/schema.graphql b/schema.graphql index fae69d34e..ec149f147 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,5 @@ # source: http://localhost:4000/api -# timestamp: Wed Oct 30 2019 17:12:28 GMT+0100 (Central European Standard Time) +# timestamp: Wed Nov 06 2019 12:50:45 GMT+0100 (Central European Standard Time) schema { query: RootQueryType @@ -119,9 +119,6 @@ type Address { country: String description: String - """The floor this event is at""" - floor: String - """The geocoordinates for the point where this address is""" geom: Point id: ID @@ -141,9 +138,6 @@ input AddressInput { country: String description: String - """The floor this event is at""" - floor: String - """The geocoordinates for the point where this address is""" geom: Point id: ID diff --git a/test/mobilizon/addresses/addresses_test.exs b/test/mobilizon/addresses/addresses_test.exs index 8be79b2f1..e4e3e8b1e 100644 --- a/test/mobilizon/addresses/addresses_test.exs +++ b/test/mobilizon/addresses/addresses_test.exs @@ -12,7 +12,6 @@ defmodule Mobilizon.AddressesTest do locality: "some addressLocality", region: "some addressRegion", description: "some description", - floor: "some floor", postal_code: "some postalCode", street: "some streetAddress", geom: %Geo.Point{coordinates: {10, -10}, srid: 4326} @@ -22,7 +21,6 @@ defmodule Mobilizon.AddressesTest do locality: "some updated addressLocality", region: "some updated addressRegion", description: "some updated description", - floor: "some updated floor", postal_code: "some updated postalCode", street: "some updated streetAddress", geom: %Geo.Point{coordinates: {20, -20}, srid: 4326} @@ -32,7 +30,6 @@ defmodule Mobilizon.AddressesTest do # addressLocality: nil, # addressRegion: nil, # description: nil, - # floor: nil, # postalCode: nil, # streetAddress: nil, # geom: nil @@ -54,7 +51,6 @@ defmodule Mobilizon.AddressesTest do assert address.locality == "some addressLocality" assert address.region == "some addressRegion" assert address.description == "some description" - assert address.floor == "some floor" assert address.postal_code == "some postalCode" assert address.street == "some streetAddress" end @@ -66,7 +62,6 @@ defmodule Mobilizon.AddressesTest do assert address.locality == "some updated addressLocality" assert address.region == "some updated addressRegion" assert address.description == "some updated description" - assert address.floor == "some updated floor" assert address.postal_code == "some updated postalCode" assert address.street == "some updated streetAddress" end diff --git a/test/support/factory.ex b/test/support/factory.ex index 336eccdf2..371c73e1c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -83,7 +83,6 @@ defmodule Mobilizon.Factory do description: sequence("MyAddress"), geom: %Geo.Point{coordinates: {45.75, 4.85}, srid: 4326}, url: "http://mobilizon.test/address/#{Ecto.UUID.generate()}", - floor: "Myfloor", country: "My Country", locality: "My Locality", region: "My Region", From c599a47d5887ec953ee3defea669115332c69dc2 Mon Sep 17 00:00:00 2001 From: Thomas Citharel <tcit@tcit.fr> Date: Fri, 8 Nov 2019 19:37:14 +0100 Subject: [PATCH 2/2] Introduce Mimirsbrunn geocoder and improve addresses & maps Signed-off-by: Thomas Citharel <tcit@tcit.fr> --- config/config.exs | 3 + config/dev.exs | 2 +- js/package.json | 2 + .../components/Event/AddressAutoComplete.vue | 241 +++++++++++++----- js/src/components/Map.vue | 58 ++++- .../Map/Vue2LeafletLocateControl.vue | 47 ++++ js/src/graphql/address.ts | 38 ++- js/src/graphql/config.ts | 8 +- js/src/graphql/event.ts | 4 +- js/src/i18n/en_US.json | 7 +- js/src/i18n/fr_FR.json | 51 ++-- js/src/types/address.model.ts | 87 ++++++- js/src/types/config.model.ts | 6 + js/src/types/event.model.ts | 4 +- js/src/utils/.editorconfig | 22 ++ js/src/utils/poiIcons.ts | 61 +++++ js/src/views/Event/Event.vue | 78 +++--- js/src/vue-apollo.ts | 9 +- js/yarn.lock | 14 +- lib/mobilizon/addresses/address.ex | 5 +- lib/mobilizon/events/event.ex | 21 +- lib/mobilizon_web/resolvers/address.ex | 44 ++-- lib/mobilizon_web/resolvers/config.ex | 23 +- lib/mobilizon_web/schema/address.ex | 5 + lib/mobilizon_web/schema/config.ex | 8 + lib/service/geospatial/google_maps.ex | 8 +- lib/service/geospatial/mimirsbrunn.ex | 146 +++++++++++ lib/service/geospatial/nominatim.ex | 80 +++--- lib/service/geospatial/provider.ex | 1 + .../20191106141051_add_type_to_addresses.exs | 9 + schema.graphql | 14 +- .../geospatial/nominatim/geocode.json | 10 +- .../geospatial/nominatim/search.json | 10 +- .../service/geospatial/nominatim_test.exs | 67 ++--- .../resolvers/address_resolver_test.exs | 10 +- test/support/mocks/geospatial_mock.ex | 4 +- 36 files changed, 940 insertions(+), 267 deletions(-) create mode 100644 js/src/components/Map/Vue2LeafletLocateControl.vue create mode 100644 js/src/utils/.editorconfig create mode 100644 js/src/utils/poiIcons.ts create mode 100644 lib/service/geospatial/mimirsbrunn.ex create mode 100644 priv/repo/migrations/20191106141051_add_type_to_addresses.exs diff --git a/config/config.exs b/config/config.exs index 243c7c0b4..8bf3b54e3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -137,6 +137,9 @@ config :mobilizon, Mobilizon.Service.Geospatial.GoogleMaps, config :mobilizon, Mobilizon.Service.Geospatial.MapQuest, api_key: System.get_env("GEOSPATIAL_MAP_QUEST_API_KEY") || nil +config :mobilizon, Mobilizon.Service.Geospatial.Mimirsbrunn, + endpoint: System.get_env("GEOSPATIAL_MIMIRSBRUNN_ENDPOINT") || nil + config :mobilizon, Oban, repo: Mobilizon.Storage.Repo, prune: {:maxlen, 10_000}, diff --git a/config/dev.exs b/config/dev.exs index ce28a21d0..ac5f5a921 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -52,7 +52,7 @@ config :mobilizon, MobilizonWeb.Endpoint, # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n", level: :debug -config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.GoogleMaps +config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Nominatim # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. diff --git a/js/package.json b/js/package.json index 7af8d1cf1..f5e1d6220 100644 --- a/js/package.json +++ b/js/package.json @@ -26,6 +26,7 @@ "graphql-tag": "^2.10.1", "intersection-observer": "^0.7.0", "leaflet": "^1.4.0", + "leaflet.locatecontrol": "^0.68.0", "lodash": "^4.17.11", "ngeohash": "^0.6.3", "register-service-worker": "^1.6.2", @@ -44,6 +45,7 @@ "devDependencies": { "@types/chai": "^4.2.3", "@types/leaflet": "^1.5.2", + "@types/leaflet.locatecontrol": "^0.60.7", "@types/lodash": "^4.14.141", "@types/mocha": "^5.2.6", "@vue/cli-plugin-babel": "^4.0.3", diff --git a/js/src/components/Event/AddressAutoComplete.vue b/js/src/components/Event/AddressAutoComplete.vue index c3b83fd3e..8b3e6f246 100644 --- a/js/src/components/Event/AddressAutoComplete.vue +++ b/js/src/components/Event/AddressAutoComplete.vue @@ -1,125 +1,242 @@ <template> <div> - <b-field :label="$t('Find an address')"> + <b-field expanded> + <template slot="label"> + {{ $t('Find an address') }} + <b-button v-if="!gettingLocation" size="is-small" icon-right="map-marker" @click="locateMe" /> + <span v-else>{{ $t('Getting location') }}</span> + </template> <b-autocomplete :data="data" v-model="queryText" :placeholder="$t('e.g. 10 Rue Jangot')" - field="description" + field="fullName" :loading="isFetching" @typing="getAsyncData" icon="map-marker" - @select="option => selected = option"> + expanded + @select="updateSelected"> <template slot-scope="{option}"> - <b>{{ option.description }}</b><br /> - <i v-if="option.url != null">Local</i> - <p> - <small>{{ option.street }},  {{ option.postalCode }} {{ option.locality }}</small> - </p> + <b-icon :icon="option.poiInfos.poiIcon.icon" /> + <b>{{ option.poiInfos.name }}</b><br /> + <small>{{ option.poiInfos.alternativeName }}</small> </template> <template slot="empty"> - <span v-if="queryText.length < 5">{{ $t('Please type at least 5 characters') }}</span> - <span v-else-if="isFetching">{{ $t('Searching…') }}</span> + <span v-if="isFetching">{{ $t('Searching…') }}</span> <div v-else class="is-enabled"> - <span>{{ $t('No results for "{queryText}"', { queryText }) }}</span> - <p class="control" @click="addressModalActive = true"> - <button type="button" class="button is-primary">{{ $t('Add') }}</button> - </p> + <span>{{ $t('No results for "{queryText}". You can try another search term or drag and drop the marker on the map', { queryText }) }}</span> +<!-- <p class="control" @click="openNewAddressModal">--> +<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>--> +<!-- </p>--> </div> </template> </b-autocomplete> </b-field> - <b-modal :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep"> - <div class="modal-card" style="width: auto"> - <header class="modal-card-head"> - <p class="modal-card-title">{{ $t('Add an address') }}</p> - </header> - <section class="modal-card-body"> - <form> - <b-field :label="$t('Name')"> - <b-input aria-required="true" required v-model="selected.description" /> - </b-field> + <div class="map" v-if="selected && selected.geom"> + <map-leaflet + :coords="selected.geom" + :marker="{ text: [selected.poiInfos.name, selected.poiInfos.alternativeName], icon: selected.poiInfos.poiIcon.icon}" + :updateDraggableMarkerCallback="reverseGeoCode" + :options="{ zoom: mapDefaultZoom }" + :readOnly="false" + /> + </div> +<!-- <b-modal v-if="selected" :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">--> +<!-- <div class="modal-card" style="width: auto">--> +<!-- <header class="modal-card-head">--> +<!-- <p class="modal-card-title">{{ $t('Add an address') }}</p>--> +<!-- </header>--> +<!-- <section class="modal-card-body">--> +<!-- <form>--> +<!-- <b-field :label="$t('Name')">--> +<!-- <b-input aria-required="true" required v-model="selected.description" />--> +<!-- </b-field>--> - <b-field :label="$t('Street')"> - <b-input v-model="selected.street" /> - </b-field> +<!-- <b-field :label="$t('Street')">--> +<!-- <b-input v-model="selected.street" />--> +<!-- </b-field>--> - <b-field :label="$t('Postal Code')"> - <b-input v-model="selected.postalCode" /> - </b-field> +<!-- <b-field grouped>--> +<!-- <b-field :label="$t('Postal Code')">--> +<!-- <b-input v-model="selected.postalCode" />--> +<!-- </b-field>--> - <b-field :label="$t('Locality')"> - <b-input v-model="selected.locality" /> - </b-field> +<!-- <b-field :label="$t('Locality')">--> +<!-- <b-input v-model="selected.locality" />--> +<!-- </b-field>--> +<!-- </b-field>--> - <b-field :label="$t('Region')"> - <b-input v-model="selected.region" /> - </b-field> +<!-- <b-field grouped>--> +<!-- <b-field :label="$t('Region')">--> +<!-- <b-input v-model="selected.region" />--> +<!-- </b-field>--> - <b-field :label="$t('Country')"> - <b-input v-model="selected.country" /> - </b-field> - </form> - </section> - <footer class="modal-card-foot"> - <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button> - </footer> - </div> - </b-modal> +<!-- <b-field :label="$t('Country')">--> +<!-- <b-input v-model="selected.country" />--> +<!-- </b-field>--> +<!-- </b-field>--> +<!-- </form>--> +<!-- </section>--> +<!-- <footer class="modal-card-foot">--> +<!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>--> +<!-- </footer>--> +<!-- </div>--> +<!-- </b-modal>--> </div> </template> <script lang="ts"> import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Address, IAddress } from '@/types/address.model'; -import { ADDRESS } from '@/graphql/address'; +import { ADDRESS, REVERSE_GEOCODE } from '@/graphql/address'; import { Modal } from 'buefy/dist/components/dialog'; +import { LatLng } from 'leaflet'; + @Component({ components: { + 'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'), Modal, }, }) export default class AddressAutoComplete extends Vue { - @Prop({ required: false, default: () => [] }) initialData!: IAddress[]; - @Prop({ required: false }) value!: IAddress; + @Prop({ required: true }) value!: IAddress; - data: IAddress[] = this.initialData; - selected: IAddress|null = new Address(); + data: IAddress[] = []; + selected!: IAddress; isFetching: boolean = false; - queryText: string = this.value && this.value.description || ''; + queryText: string = this.value && (new Address(this.value)).fullName || ''; addressModalActive: boolean = false; + private gettingLocation: boolean = false; + private location!: Position; + private gettingLocationError: any; + private mapDefaultZoom: number = 15; + + @Watch('value') + updateEditing() { + this.selected = this.value; + const address = new Address(this.selected); + this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`; + } async getAsyncData(query) { - if (query.length < 5) { + if (!query.length) { + this.data = []; + this.selected = new Address(); + return; + } + + if (query.length < 3) { this.data = []; return; } this.isFetching = true; const result = await this.$apollo.query({ query: ADDRESS, - fetchPolicy: 'no-cache', - variables: { query }, + fetchPolicy: 'network-only', + variables: { + query, + locale: this.$i18n.locale, + }, }); - this.data = result.data.searchAddress as IAddress[]; + this.data = result.data.searchAddress.map(address => new Address(address)); this.isFetching = false; } - // Watch deep because of subproperties - @Watch('selected', { deep: true }) - updateSelected() { + updateSelected(option) { + if (option == null) return; + this.selected = option; + console.log('update selected', this.selected); this.$emit('input', this.selected); } resetPopup() { this.selected = new Address(); } + + openNewAddressModal() { + this.resetPopup(); + this.addressModalActive = true; + } + + async reverseGeoCode(e: LatLng, zoom: Number) { + // If the position has been updated through autocomplete selection, no need to geocode it ! + if (this.checkCurrentPosition(e)) return; + const result = await this.$apollo.query({ + query: REVERSE_GEOCODE, + variables: { + latitude: e.lat, + longitude: e.lng, + zoom, + locale: this.$i18n.locale, + }, + }); + + this.data = result.data.reverseGeocode.map(address => new Address(address)); + const defaultAddress = new Address(this.data[0]); + this.selected = defaultAddress; + this.$emit('input', this.selected); + this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`; + } + + checkCurrentPosition(e: LatLng) { + if (!this.selected || !this.selected.geom) return false; + const lat = parseFloat(this.selected.geom.split(';')[1]); + const lon = parseFloat(this.selected.geom.split(';')[0]); + + return e.lat === lat && e.lng === lon; + } + + async locateMe(): Promise<void> { + + this.gettingLocation = true; + try { + this.gettingLocation = false; + this.location = await this.getLocation(); + this.mapDefaultZoom = 12; + this.reverseGeoCode(new LatLng(this.location.coords.latitude, this.location.coords.longitude), 12); + } catch (e) { + this.gettingLocation = false; + this.gettingLocationError = e.message; + } + } + + async getLocation(): Promise<Position> { + return new Promise((resolve, reject) => { + + if (!('geolocation' in navigator)) { + reject(new Error('Geolocation is not available.')); + } + + navigator.geolocation.getCurrentPosition(pos => { + resolve(pos); + }, err => { + reject(err); + }); + + }); + } } </script> <style lang="scss"> - .autocomplete .dropdown-item.is-disabled .is-enabled { - opacity: 1 !important; - cursor: auto; + .autocomplete { + .dropdown-menu { + z-index: 2000; + } + + .dropdown-item.is-disabled { + opacity: 1 !important; + cursor: auto; + } + } + + .read-only { + cursor: pointer; + } + + .map { + height: 400px; + width: 100%; } </style> diff --git a/js/src/components/Map.vue b/js/src/components/Map.vue index d1c11d57e..9721cc763 100644 --- a/js/src/components/Map.vue +++ b/js/src/components/Map.vue @@ -5,40 +5,54 @@ :style="`height: ${mergedOptions.height}; width: ${mergedOptions.width}`" class="leaflet-map" :center="[lat, lon]" + @click="clickMap" + @update:zoom="updateZoom" > <l-tile-layer - url="https://{s}.tile.osm.org/{z}/{x}/{y}.png" - attribution="© OpenStreetMap contributors" + url="https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png" + :attribution="$t('© The OpenStreetMap Contributors')" > </l-tile-layer> - <l-marker :lat-lng="[lat, lon]" > - <l-popup v-if="popup">{{ popup }}</l-popup> + <v-locatecontrol :options="{icon: 'mdi mdi-map-marker'}"/> + <l-marker :lat-lng="[lat, lon]" @add="openPopup" @update:latLng="updateDraggableMarkerPosition" :draggable="!readOnly"> + <l-popup v-if="popupMultiLine"> + <span v-for="line in popupMultiLine" :key="line">{{ line }}<br /></span> + </l-popup> </l-marker> </l-map> </div> </template> <script lang="ts"> -import { Icon } from 'leaflet'; +import { Icon, LatLng, LeafletMouseEvent } from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { Component, Prop, Vue } from 'vue-property-decorator'; -import { LMap, LTileLayer, LMarker, LPopup } from 'vue2-leaflet'; +import { LMap, LTileLayer, LMarker, LPopup, LIcon } from 'vue2-leaflet'; +import Vue2LeafletLocateControl from '@/components/Map/Vue2LeafletLocateControl.vue'; @Component({ - components: { LTileLayer, LMap, LMarker, LPopup }, + components: { LTileLayer, LMap, LMarker, LPopup, LIcon, 'v-locatecontrol': Vue2LeafletLocateControl }, }) export default class Map extends Vue { + @Prop({ type: Boolean, required: false, default: true }) readOnly!: boolean; @Prop({ type: String, required: true }) coords!: string; - @Prop({ type: String, required: false }) popup!: string; + @Prop({ type: Object, required: false }) marker!: { text: String|String[], icon: String }; @Prop({ type: Object, required: false }) options!: object; + @Prop({ type: Function, required: false, default: () => {} }) updateDraggableMarkerCallback!: Function; - defaultOptions: object = { + defaultOptions: { + zoom: Number; + height: String; + width: String; + } = { zoom: 15, height: '100%', width: '100%', }; + zoom = this.defaultOptions.zoom; + mounted() { // this part resolve an issue where the markers would not appear // @ts-ignore @@ -51,12 +65,38 @@ export default class Map extends Vue { }); } + openPopup(event) { + this.$nextTick(() => { + event.target.openPopup(); + }); + } + get mergedOptions(): object { return { ...this.defaultOptions, ...this.options }; } get lat() { return this.$props.coords.split(';')[1]; } get lon() { return this.$props.coords.split(';')[0]; } + + get popupMultiLine() { + if (Array.isArray(this.marker.text)) { + return this.marker.text; + } + return [this.marker.text]; + } + + clickMap(event: LeafletMouseEvent) { + this.updateDraggableMarkerPosition(event.latlng); + } + + updateDraggableMarkerPosition(e: LatLng) { + console.log('updateDraggableMarkerPosition', e); + this.updateDraggableMarkerCallback(e, this.zoom); + } + + updateZoom(zoom: Number) { + this.zoom = zoom; + } } </script> <style lang="scss" scoped> diff --git a/js/src/components/Map/Vue2LeafletLocateControl.vue b/js/src/components/Map/Vue2LeafletLocateControl.vue new file mode 100644 index 000000000..44a7884ff --- /dev/null +++ b/js/src/components/Map/Vue2LeafletLocateControl.vue @@ -0,0 +1,47 @@ +<template> + <div style="display: none;"> + <slot v-if="ready"></slot> + </div> +</template> + +<script lang="ts"> +/** + * Fork of https://github.com/domoritz/leaflet-locatecontrol to try to trigger location manually (not done ATM) + */ + +import L, { DomEvent } from 'leaflet'; +import { findRealParent, propsBinder } from 'vue2-leaflet'; +import 'leaflet.locatecontrol'; +import { Component, Prop, Vue } from 'vue-property-decorator'; + +@Component({ + beforeDestroy() { + // @ts-ignore + this.parentContainer.removeLayer(this); + }, +}) +export default class Vue2LeafletLocateControl extends Vue { + @Prop({ type: Object, default: () => { return {}; } }) options; + @Prop({ type: Boolean, default: true }) visible = true; + ready: boolean = false; + mapObject!: any; + parentContainer: any; + + mounted() { + this.mapObject = L.control.locate(this.options); + DomEvent.on(this.mapObject, this.$listeners as any); + propsBinder(this, this.mapObject, this.$props); + this.ready = true; + this.parentContainer = findRealParent(this.$parent); + this.mapObject.addTo(this.parentContainer.mapObject, !this.visible); + } + + public locate() { + this.mapObject.start(); + } +} +</script> + +<style> + @import "~leaflet.locatecontrol/dist/L.Control.Locate.css"; +</style> diff --git a/js/src/graphql/address.ts b/js/src/graphql/address.ts index b45790051..9f3857eb5 100644 --- a/js/src/graphql/address.ts +++ b/js/src/graphql/address.ts @@ -1,20 +1,34 @@ import gql from 'graphql-tag'; +const $addressFragment = ` +id, +description, +geom, +street, +locality, +postalCode, +region, +country, +type, +url, +originId +`; + export const ADDRESS = gql` - query($query:String!) { + query($query:String!, $locale: String) { searchAddress( - query: $query + query: $query, + locale: $locale ) { - id, - description, - geom, - street, - locality, - postalCode, - region, - country, - url, - originId + ${$addressFragment} + } + } +`; + +export const REVERSE_GEOCODE = gql` + query($latitude: Float!, $longitude: Float!, $zoom: Int, $locale: String) { + reverseGeocode(latitude: $latitude, longitude: $longitude, zoom: $zoom, locale: $locale) { + ${$addressFragment} } } `; diff --git a/js/src/graphql/config.ts b/js/src/graphql/config.ts index 3258080b1..ff1f3f227 100644 --- a/js/src/graphql/config.ts +++ b/js/src/graphql/config.ts @@ -5,7 +5,13 @@ query { config { name, description, - registrationsOpen + registrationsOpen, + countryCode, + location { + latitude, + longitude, + accuracyRadius + } } } `; diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts index a26250b19..b970e7300 100644 --- a/js/src/graphql/event.ts +++ b/js/src/graphql/event.ts @@ -24,7 +24,9 @@ const physicalAddressQuery = ` region, country, geom, - id + type, + id, + originId `; const tagsQuery = ` diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json index fd0f92998..8955f1ede 100644 --- a/js/src/i18n/en_US.json +++ b/js/src/i18n/en_US.json @@ -110,6 +110,7 @@ "From the {startDate} to the {endDate}": "From the {startDate} to the {endDate}", "Gather ⋅ Organize ⋅ Mobilize": "Gather ⋅ Organize ⋅ Mobilize", "General information": "General information", + "Getting location": "Getting location", "Going as {name}": "Going as {name}", "Group List": "Group List", "Group full name": "Group full name", @@ -160,7 +161,7 @@ "No events found": "No events found", "No group found": "No group found", "No groups found": "No groups found", - "No results for \"{queryText}\"": "No results for \"{queryText}\"", + "No results for \"{queryText}\". You can try another search term or drag and drop the marker on the map": "No results for \"{queryText}\". You can try another search term or drag and drop the marker on the map", "No user account with this email was found. Maybe you made a typo?": "No user account with this email was found. Maybe you made a typo?", "Number of places": "Number of places", "OK": "OK", @@ -195,7 +196,6 @@ "Please make sure the address is correct and that the page hasn't been moved.": "Please make sure the address is correct and that the page hasn't been moved.", "Please read the full rules": "Please read the full rules", "Please refresh the page and retry.": "Please refresh the page and retry.", - "Please type at least 5 characters": "Please type at least 5 characters", "Postal Code": "Postal Code", "Private event": "Private event", "Private feeds": "Private feeds", @@ -327,5 +327,6 @@ "{count} participants": "No participants yet | One participant | {count} participants", "{count} requests waiting": "{count} requests waiting", "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.", - "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks" + "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks", + "© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors" } diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json index 46075f759..1cd12072e 100644 --- a/js/src/i18n/fr_FR.json +++ b/js/src/i18n/fr_FR.json @@ -3,14 +3,14 @@ "A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising.": "Un outil convivial, émancipateur et éthique pour se rassembler, s'organiser et se mobiliser.", "A validation email was sent to {email}": "Un email de validation a été envoyé à {email}", "Abandon edition": "Abandonner l'édition", - "About": "À propos", "About Mobilizon": "À propos de Mobilizon", "About this event": "À propos de cet événement", "About this instance": "À propos de cette instance", - "Add": "Ajouter", + "About": "À propos", "Add an address": "Ajouter une adresse", "Add some tags": "Ajouter des tags", "Add to my calendar": "Ajouter à mon agenda", + "Add": "Ajouter", "Additional comments": "Commentaires additionnels", "Administration": "Administration", "All data will be deleted every 48 hours, so please don't use this for anything real.": "Toutes les données seront effacées toutes les 48 heures, donc n'utilisez pas ce site à des fins autres que de démonstration.", @@ -25,28 +25,27 @@ "Avatar": "Avatar", "Before you can login, you need to click on the link inside it to validate your account": "Avant que vous puissiez vous enregistrer, vous devez cliquer sur le lien à l'intérieur pour valider votre compte", "By {name}": "Par {name}", - "Cancel": "Annuler", "Cancel creation": "Annuler la création", "Cancel edition": "Annuler l'édition", "Cancel my participation request…": "Annuler ma demande de participation…", "Cancel my participation…": "Annuler ma participation…", - "Cancelled: Won't happen": "Annulé : N'aura pas lieu", + "Cancel": "Annuler", + "Cancelled: Won't happen": "Annulé : N'aura pas lieu", "Category": "Catégorie", - "Change": "Modifier", "Change my identity…": "Changer mon identité…", "Change my password": "Modifier mon mot de passe", "Change password": "Modifier mot de passe", + "Change": "Modifier", "Clear": "Effacer", "Click to select": "Cliquez pour sélectionner", "Click to upload": "Cliquez pour uploader", "Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)", - "Comments": "Commentaires", "Comments on the event page": "Commentaires sur la page de l'événement", + "Comments": "Commentaires", "Confirm my particpation": "Confirmer ma participation", "Confirmed: Will happen": "Confirmé : aura lieu", "Continue editing": "Continuer l'édition", "Country": "Pays", - "Create": "Créer", "Create a new event": "Créer un nouvel événement", "Create a new group": "Créer un nouveau groupe", "Create a new identity": "Créer une nouvelle identité", @@ -57,16 +56,17 @@ "Create my profile": "Créer mon profil", "Create token": "Créer un jeton", "Create, edit or delete events": "Créer, modifier ou supprimer des événements", + "Create": "Créer", "Creator": "Créateur", "Current identity has been changed to {identityName} in order to manage this event.": "L'identité actuelle a été changée à {identityName} pour pouvoir gérer cet événement.", "Date and time settings": "Paramètres de date et d'heure", "Date parameters": "Paramètres de date", - "Delete": "Supprimer", "Delete event": "Supprimer un événement", "Delete this identity": "Supprimer cette identité", "Delete your identity": "Supprimer votre identité", "Delete {eventTitle}": "Supprimer {eventTitle}", "Delete {preferredUsername}": "Supprimer {preferredUsername}", + "Delete": "Supprimer", "Description": "Description", "Didn't receive the instructions ?": "Vous n'avez pas reçu les instructions ?", "Display name": "Nom affiché", @@ -84,7 +84,6 @@ "Error while communicating with the server.": "Erreur de communication avec le serveur.", "Error while saving report.": "Erreur lors de l'enregistrement du signalement.", "Error while validating account": "Erreur lors de la validation du compte", - "Event": "Événement", "Event already passed": "Événement déjà passé", "Event cancelled": "Événement annulé", "Event creation": "Création d'événement", @@ -95,6 +94,7 @@ "Event to be confirmed": "Événement à confirmer", "Event {eventTitle} deleted": "Événement {eventTitle} supprimé", "Event {eventTitle} reported": "Événement {eventTitle} signalé", + "Event": "Événement", "Events": "Événements", "Exclude": "Exclure", "Explore": "Explorer", @@ -102,14 +102,15 @@ "Features": "Fonctionnalités", "Find an address": "Trouver une adresse", "Find an instance": "Trouver une instance", - "For instance: London, Taekwondo, Architecture…": "Par exemple : Lyon, Taekwondo, Architecture…", + "For instance: London, Taekwondo, Architecture…": "Par exemple : Lyon, Taekwondo, Architecture…", "Forgot your password ?": "Mot de passe oublié ?", "From a birthday party with friends and family to a march for climate change, right now, our gatherings are <b>trapped inside the tech giants’ platforms</b>. How can we organize, how can we click “Attend,” without <b>providing private data</b> to Facebook or <b>locking ourselves up</b> inside MeetUp?": "De l’anniversaire entre ami·e·s à une marche pour le climat, aujourd’hui, les bonnes raisons de se rassembler sont <b>captées par les géants du web</b>. Comment s’organiser, comment cliquer sur « je participe » sans <b>livrer des données intimes</b> à Facebook ou<b> s’enfermer</b> dans MeetUp ?", - "From the {startDate} at {startTime} to the {endDate}": "Du {startDate} à {startTime} jusqu'au {endDate}", "From the {startDate} at {startTime} to the {endDate} at {endTime}": "Du {startDate} à {startTime} au {endDate} à {endTime}", + "From the {startDate} at {startTime} to the {endDate}": "Du {startDate} à {startTime} jusqu'au {endDate}", "From the {startDate} to the {endDate}": "Du {startDate} au {endDate}", "Gather ⋅ Organize ⋅ Mobilize": "Rassembler ⋅ Organiser ⋅ Mobiliser", "General information": "Informations générales", + "Getting location": "Récupération de la position", "Going as {name}": "En tant que {name}", "Group List": "Liste de groupes", "Group full name": "Nom complet du groupe", @@ -131,8 +132,8 @@ "Join {instance}, a Mobilizon instance": "Rejoignez {instance}, une instance Mobilizon", "Last published event": "Dernier événement publié", "Last week": "La semaine dernière", - "Learn more": "En apprendre plus", "Learn more about Mobilizon": "En apprendre plus à propos de Mobilizon", + "Learn more": "En apprendre plus", "Leave event": "Annuler ma participation à l'événement", "Leaving event \"{title}\"": "Annuler ma participation à l'événement", "Let's create a new common": "Créons un nouveau Common", @@ -142,8 +143,8 @@ "Locality": "Commune", "Log in": "Se connecter", "Log out": "Se déconnecter", - "Login": "Se connecter", "Login on Mobilizon!": "Se connecter sur Mobilizon !", + "Login": "Se connecter", "Manage participations": "Gérer les participations", "Members": "Membres", "Mobilizon is a free/libre software that will allow communities to create <b>their own spaces</b> to publish events in order to better emancipate themselves from tech giants.": "Mobilizon est un logiciel libre qui permettra à des communautés de <b>créer leurs propres espaces</b> de publication d’événements, afin de mieux s’émanciper des géants du web.", @@ -161,19 +162,20 @@ "No group found": "Aucun groupe trouvé", "No groups found": "Aucun groupe trouvé", "No results for \"{queryText}\"": "Pas de résultats pour « {queryText} »", + "No results for \"{queryText}\". You can try another search term or drag and drop the marker on the map": "Pas de résultats pour « {queryText} ». Vous pouvez essayer avec d'autres termes de recherche ou bien glisser et déposer le marqueur sur la carte", "No user account with this email was found. Maybe you made a typo?": "Aucun compte utilisateur trouvé pour cet email. Peut-être avez-vous fait une faute de frappe ?", "Number of places": "Nombre de places", "OK": "OK", "Old password": "Ancien mot de passe", - "On {date}": "Le {date}", "On {date} ending at {endTime}": "Le {date}, se terminant à {endTime}", "On {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}", "On {date} starting at {startTime}": "Le {date} à partir de {startTime}", + "On {date}": "Le {date}", "One person is going": "Personne n'y va | Une personne y va | {approved} personnes y vont", "Only accessible through link and search (private)": "Uniquement accessibles par lien et la recherche (privé)", "Opened reports": "Signalements ouverts", - "Organized": "Organisés", "Organized by {name}": "Organisé par {name}", + "Organized": "Organisés", "Organizer": "Organisateur", "Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.", "Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)", @@ -184,10 +186,10 @@ "Participate": "Participer", "Participation approval": "Validation des participations", "Participation requested!": "Participation demandée !", - "Password": "Mot de passe", "Password (confirmation)": "Mot de passe (confirmation)", "Password change": "Changement de mot de passe", "Password reset": "Réinitialisation du mot de passe", + "Password": "Mot de passe", "Past events": "Événements passés", "Pick an identity": "Choisissez une identité", "Please check your spam folder if you didn't receive the email.": "Merci de vérifier votre dossier des indésirables si vous n'avez pas reçu l'email.", @@ -209,23 +211,23 @@ "RSS/Atom Feed": "Flux RSS/Atom", "Read Framasoft’s statement of intent on the Framablog": "Lire la note d’intention de Framasoft sur le Framablog", "Region": "Région", - "Register": "S'inscrire", "Register an account on Mobilizon!": "S'inscrire sur Mobilizon !", "Register for an event by choosing one of your identities": "S'inscrire à un événement en choisissant une de vos identités", + "Register": "S'inscrire", "Registration is currently closed.": "Les inscriptions sont actuellement fermées.", "Reject": "Rejetter", - "Rejected": "Rejetés", "Rejected participations": "Participations rejetées", - "Report": "Signaler", + "Rejected": "Rejetés", "Report this event": "Signaler cet événement", + "Report": "Signaler", "Requests": "Requêtes", "Resend confirmation email": "Envoyer à nouveau l'email de confirmation", "Reset my password": "Réinitialiser mon mot de passe", - "Save": "Enregistrer", "Save draft": "Enregistrer le brouillon", - "Search": "Rechercher", + "Save": "Enregistrer", "Search events, groups, etc.": "Rechercher des événements, des groupes, etc.", - "Search results: \"{search}\"": "Résultats de recherche : « {search} »", + "Search results: \"{search}\"": "Résultats de recherche : « {search} »", + "Search": "Rechercher", "Searching…": "Recherche en cours…", "Send me an email to reset my password": "Envoyez-moi un email pour réinitialiser mon mot de passe", "Send me the confirmation email once again": "Envoyez-moi l'email de confirmation encore une fois", @@ -246,8 +248,8 @@ "The draft event has been updated": "L'événement brouillon a été mis à jour", "The event has been created as a draft": "L'événement a été créé en tant que brouillon", "The event has been published": "L'événement a été publié", - "The event has been updated": "L'événement a été mis à jour", "The event has been updated and published": "L'événement a été mis à jour et publié", + "The event has been updated": "L'événement a été mis à jour", "The event organizer didn't add any description.": "L'organisateur de l'événement n'a pas ajouté de description.", "The event title will be ellipsed.": "Le titre de l'événement sera ellipsé.", "The page you're looking for doesn't exist.": "La page que vous recherchez n'existe pas.", @@ -327,5 +329,6 @@ "{count} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s", "{count} requests waiting": "Une demande en attente|{count} demandes en attente", "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.", - "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines" + "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines", + "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" } diff --git a/js/src/types/address.model.ts b/js/src/types/address.model.ts index 8db566691..d340e0db6 100644 --- a/js/src/types/address.model.ts +++ b/js/src/types/address.model.ts @@ -1,11 +1,14 @@ +import poiIcons from '@/utils/poiIcons'; + export interface IAddress { - id?: number; + id?: string; description: string; street: string; locality: string; postalCode: string; region: string; country: string; + type: string; geom?: string; url?: string; originId?: string; @@ -18,4 +21,86 @@ export class Address implements IAddress { postalCode: string = ''; region: string = ''; street: string = ''; + type: string = ''; + id?: string = ''; + originId?: string = ''; + url?: string = ''; + geom?: string = ''; + + constructor(hash?) { + if (!hash) return; + + this.id = hash.id; + this.description = hash.description; + this.street = hash.street; + this.locality = hash.locality; + this.postalCode = hash.postalCode; + this.region = hash.region; + this.country = hash.country; + this.type = hash.type; + this.geom = hash.geom; + this.url = hash.url; + this.originId = hash.originId; + } + + get poiInfos() { + /* generate name corresponding to poi type */ + let name = ''; + let alternativeName = ''; + let poiIcon = poiIcons.default; + // Google Maps doesn't have a type + if (this.type == null && this.description === this.street) this.type = 'house'; + + switch (this.type) { + case 'house': + name = this.description; + alternativeName = [this.postalCode, this.locality, this.country].filter(zone => zone).join(', '); + poiIcon = poiIcons.defaultAddress; + break; + case 'street': + case 'secondary': + name = this.description; + alternativeName = [this.postalCode, this.locality, this.country].filter(zone => zone).join(', '); + poiIcon = poiIcons.defaultStreet; + break; + case 'zone': + case 'city': + case 'administrative': + name = this.postalCode ? `${this.description} (${this.postalCode})` : this.description; + alternativeName = [this.region, this.country].filter(zone => zone).join(', '); + poiIcon = poiIcons.defaultAdministrative; + break; + default: + // POI + name = this.description; + alternativeName = ''; + if (this.street && this.street.trim()) { + alternativeName = `${this.street}`; + if (this.locality) { + alternativeName += ` (${this.locality})`; + } + } else if (this.locality && this.locality.trim()) { + alternativeName = `${this.locality}, ${this.region}, ${this.country}`; + } else { + alternativeName = `${this.region}, ${this.country}`; + } + poiIcon = this.iconForPOI; + break; + } + return { name, alternativeName, poiIcon }; + } + + get fullName() { + const { name, alternativeName } = this.poiInfos; + return `${name}, ${alternativeName}`; + } + + get iconForPOI() { + if (this.type == null) { + return poiIcons.default; + } + const type = this.type.split(':').pop() || ''; + if (poiIcons[type]) return poiIcons[type]; + return poiIcons.default; + } } diff --git a/js/src/types/config.model.ts b/js/src/types/config.model.ts index 84f15eb6d..8da0a0c90 100644 --- a/js/src/types/config.model.ts +++ b/js/src/types/config.model.ts @@ -3,4 +3,10 @@ export interface IConfig { description: string; registrationsOpen: boolean; + countryCode: string; + location: { + latitude: number; + longitude: number; + accuracyRadius: number; + }; } diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts index 494dbb45d..3c29374d1 100644 --- a/js/src/types/event.model.ts +++ b/js/src/types/event.model.ts @@ -1,5 +1,5 @@ import { Actor, IActor, IPerson } from './actor'; -import { IAddress } from '@/types/address.model'; +import { Address, IAddress } from '@/types/address.model'; import { ITag } from '@/types/tag.model'; import { IPicture } from '@/types/picture.model'; @@ -239,7 +239,7 @@ export class EventModel implements IEvent { this.onlineAddress = hash.onlineAddress; this.phoneAddress = hash.phoneAddress; - this.physicalAddress = hash.physicalAddress; + this.physicalAddress = new Address(hash.physicalAddress); this.participantStats = hash.participantStats; this.tags = hash.tags; diff --git a/js/src/utils/.editorconfig b/js/src/utils/.editorconfig new file mode 100644 index 000000000..b6b82f05c --- /dev/null +++ b/js/src/utils/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +trim_trailing_whitespace = true + +[*.ex] +indent_size = 2 +tab_width = 2 + +[*.scss] +indent_size = 2 + +[*.ts] +indent_size = 2 +tab_width = 2 diff --git a/js/src/utils/poiIcons.ts b/js/src/utils/poiIcons.ts new file mode 100644 index 000000000..eb384aa0e --- /dev/null +++ b/js/src/utils/poiIcons.ts @@ -0,0 +1,61 @@ +export default { + default: { + icon: 'map-marker', + color: '#5C6F84', + }, + defaultAdministrative: { + icon: 'city', + color: '#5c6f84', + }, + defaultStreet: { + icon: 'road-variant', + color: '#5c6f84', + }, + defaultAddress: { + icon: 'home', + color: '#5c6f84', + }, + place_house: { + icon: 'home', + color: '#5c6f84', + }, + theatre: { + icon: 'drama-masks', + }, + parking: { + icon: 'parking', + }, + police: { + icon: 'police-badge', + }, + post_office: { + icon: 'email', + }, + university: { + icon: 'school', + }, + college: { + icon: 'school', + }, + park: { + icon: 'pine-tree', + }, + garden: { + icon: 'pine-tree', + }, + bicycle_rental: { + icon: 'bicycle', + }, + hospital: { + icon: 'hospital-box', + }, + townhall: { + icon: 'office-building', + }, + toilets: { + icon: 'human-male-female', + }, + hairdresser: { + icon: 'content-cut', + }, +}; diff --git a/js/src/views/Event/Event.vue b/js/src/views/Event/Event.vue index 465b3d8ec..50d2e0918 100644 --- a/js/src/views/Event/Event.vue +++ b/js/src/views/Event/Event.vue @@ -1,4 +1,3 @@ -import {ParticipantRole} from "@/types/event.model"; <template> <div class="container"> <b-loading :active.sync="$apollo.loading"></b-loading> @@ -15,7 +14,7 @@ import {ParticipantRole} from "@/types/event.model"; <div class="title-and-informations"> <h1 class="title">{{ event.title }}</h1> <span> - <router-link v-if="actorIsOrganizer" :to="{ name: RouteName.PARTICIPATIONS, params: {eventId: event.uuid}}"> + <router-link v-if="actorIsOrganizer && event.draft === false" :to="{ name: RouteName.PARTICIPATIONS, params: {eventId: event.uuid}}"> <small v-if="event.participantStats.going > 0 && !actorIsParticipant"> {{ $tc('One person is going', event.participantStats.going, {approved: event.participantStats.going}) }} </small> @@ -111,23 +110,27 @@ import {ParticipantRole} from "@/types/event.model"; </p> </div> <div class="address-wrapper"> - <b-icon icon="map" /> - <span v-if="!event.physicalAddress">{{ $t('No address defined') }}</span> - <div class="address" v-if="event.physicalAddress"> - <address> - <span class="addressDescription" :title="event.physicalAddress.description">{{ event.physicalAddress.description }}</span> - <span>{{ event.physicalAddress.street }}</span> - <span>{{ event.physicalAddress.postalCode }} {{ event.physicalAddress.locality }}</span> - </address> - <span class="map-show-button" @click="showMap = !showMap" v-if="event.physicalAddress && event.physicalAddress.geom"> + <span v-if="!physicalAddress"> + <b-icon icon="map" /> + {{ $t('No address defined') }} + </span> + <div class="address" v-if="physicalAddress"> + <span> + <b-icon :icon="physicalAddress.poiInfos.poiIcon.icon" /> + <address> + <span class="addressDescription" :title="physicalAddress.poiInfos.name">{{ physicalAddress.poiInfos.name }}</span> + <span>{{ physicalAddress.poiInfos.alternativeName }}</span> + </address> + </span> + <span class="map-show-button" @click="showMap = !showMap" v-if="physicalAddress && physicalAddress.geom"> {{ $t('Show map') }} </span> </div> - <b-modal v-if="event.physicalAddress && event.physicalAddress.geom" :active.sync="showMap" scroll="keep"> + <b-modal v-if="physicalAddress && physicalAddress.geom" :active.sync="showMap" scroll="keep"> <div class="map"> <map-leaflet - :coords="event.physicalAddress.geom" - :popup="event.physicalAddress.description" + :coords="physicalAddress.geom" + :marker="{ text: physicalAddress.fullName, icon: physicalAddress.poiInfos.poiIcon.icon }" /> </div> </b-modal> @@ -254,7 +257,7 @@ import IdentityPicker from '@/views/Account/IdentityPicker.vue'; import ParticipationButton from '@/components/Event/ParticipationButton.vue'; import { GraphQLError } from 'graphql'; import { RouteName } from '@/router'; -import HTML = Mocha.reporters.HTML; +import { Address } from '@/types/address.model'; @Component({ components: { @@ -596,11 +599,13 @@ export default class Event extends EventMixin { } get eventCapacityOK(): boolean { + if (this.event.draft) return true; if (!this.event.options.maximumAttendeeCapacity) return true; return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant; } get numberOfPlacesStillAvailable(): number { + if (this.event.draft) return this.event.options.maximumAttendeeCapacity; return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant; } @@ -611,6 +616,11 @@ export default class Event extends EventMixin { return null; } } + + get physicalAddress(): Address|null { + if (!this.event.physicalAddress) return null; + return new Address(this.event.physicalAddress); + } } </script> <style lang="scss" scoped> @@ -664,25 +674,33 @@ export default class Event extends EventMixin { cursor: pointer; } - address { - font-style: normal; - flex-wrap: wrap; + span:first-child { display: flex; - justify-content: flex-start; - span.addressDescription { - text-overflow: ellipsis; - white-space: nowrap; - flex: 1 0 auto; - min-width: 100%; - max-width: 4rem; - overflow: hidden; + span.icon { + align-self: center; } - :not(.addressDescription) { - color: rgba(46, 62, 72, .6); - flex: 1; - min-width: 100%; + address { + font-style: normal; + flex-wrap: wrap; + display: flex; + justify-content: flex-start; + + span.addressDescription { + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 0 auto; + min-width: 100%; + max-width: 4rem; + overflow: hidden; + } + + :not(.addressDescription) { + color: rgba(46, 62, 72, .6); + flex: 1; + min-width: 100%; + } } } } diff --git a/js/src/vue-apollo.ts b/js/src/vue-apollo.ts index 06ffa767b..10a045cf1 100644 --- a/js/src/vue-apollo.ts +++ b/js/src/vue-apollo.ts @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { ApolloLink, Observable } from 'apollo-link'; -import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import { defaultDataIdFromObject, InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import { onError } from 'apollo-link-error'; import { createLink } from 'apollo-absinthe-upload-link'; import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint'; @@ -132,6 +132,13 @@ const link = authMiddleware const cache = new InMemoryCache({ fragmentMatcher, + dataIdFromObject: object => { + if (object.__typename === 'Address') { + // @ts-ignore + return object.origin_id; + } + return defaultDataIdFromObject(object); + }, }); const apolloClient = new ApolloClient({ diff --git a/js/yarn.lock b/js/yarn.lock index 3ea01ba69..9d93d1009 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -927,7 +927,14 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636" integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A== -"@types/leaflet@^1.5.2": +"@types/leaflet.locatecontrol@^0.60.7": + version "0.60.7" + resolved "https://registry.yarnpkg.com/@types/leaflet.locatecontrol/-/leaflet.locatecontrol-0.60.7.tgz#96d258bf27376b53bb4b3e9276a14e38f270215b" + integrity sha512-sac/MeK4gB+3XTJ3JzCe3HqLwKNHblIpZrxUJ6FapWK8uISZ0wcy8motVO7+v/yO47tQgsnYaobwFZ//beWHBQ== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet@*", "@types/leaflet@^1.5.2": version "1.5.5" resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.5.5.tgz#006c0aa89c4b5e62941717fa71a09e846423536c" integrity sha512-Eyh1LMmW4OFgafL6rjLyGkMqFS5IzgwWHMSgTKbrsvwLjLaWH8Ae8CV5liRe8HSM731oOVDwAMIZgg9P0SO9tg== @@ -7404,6 +7411,11 @@ lcid@^2.0.0: dependencies: invert-kv "^2.0.0" +leaflet.locatecontrol@^0.68.0: + version "0.68.0" + resolved "https://registry.yarnpkg.com/leaflet.locatecontrol/-/leaflet.locatecontrol-0.68.0.tgz#fc0d173ef0f6670af192641e5a448f0c58c814d3" + integrity sha512-jXJCpBvkyH6shjPEOK/DWu/tKX/WdkNeO96jyPrnGelYp9u6wSDj4V1V4aX9+CMTIrEyVB4/4XuU+T7VTRpb6w== + leaflet@^1.4.0: version "1.5.1" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.5.1.tgz#9afb9d963d66c870066b1342e7a06f92840f46bf" diff --git a/lib/mobilizon/addresses/address.ex b/lib/mobilizon/addresses/address.ex index 0b2de0930..ec04a749a 100644 --- a/lib/mobilizon/addresses/address.ex +++ b/lib/mobilizon/addresses/address.ex @@ -17,6 +17,7 @@ defmodule Mobilizon.Addresses.Address do geom: Geo.PostGIS.Geometry.t(), postal_code: String.t(), street: String.t(), + type: String.t(), url: String.t(), origin_id: String.t(), events: [Event.t()] @@ -31,7 +32,8 @@ defmodule Mobilizon.Addresses.Address do :region, :postal_code, :street, - :origin_id + :origin_id, + :type ] @attrs @required_attrs ++ @optional_attrs @@ -43,6 +45,7 @@ defmodule Mobilizon.Addresses.Address do field(:geom, Geo.PostGIS.Geometry) field(:postal_code, :string) field(:street, :string) + field(:type, :string) field(:url, :string) field(:origin_id, :string) diff --git a/lib/mobilizon/events/event.ex b/lib/mobilizon/events/event.ex index 2978e785a..ce0b2b5ad 100644 --- a/lib/mobilizon/events/event.ex +++ b/lib/mobilizon/events/event.ex @@ -28,6 +28,7 @@ defmodule Mobilizon.Events.Event do alias Mobilizon.Media alias Mobilizon.Media.Picture alias Mobilizon.Mention + alias Mobilizon.Storage.Repo alias MobilizonWeb.Endpoint alias MobilizonWeb.Router.Helpers, as: Routes @@ -105,7 +106,7 @@ defmodule Mobilizon.Events.Event do embeds_one(:participant_stats, EventParticipantStats, on_replace: :update) belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id) belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id) - belongs_to(:physical_address, Address, on_replace: :update) + belongs_to(:physical_address, Address, on_replace: :nilify) belongs_to(:picture, Picture, on_replace: :update) has_many(:tracks, Track) has_many(:sessions, Session) @@ -194,11 +195,23 @@ defmodule Mobilizon.Events.Event do put_assoc(changeset, :physical_address, address) _ -> - changeset + cast_assoc(changeset, :physical_address) end end - # In case it's a new address + # In case it's a new address but the origin_id is an existing one + defp put_address(%Changeset{} = changeset, %{physical_address: %{origin_id: origin_id}}) + when not is_nil(origin_id) do + case Repo.get_by(Address, origin_id: origin_id) do + %Address{} = address -> + put_assoc(changeset, :physical_address, address) + + _ -> + cast_assoc(changeset, :physical_address) + end + end + + # In case it's a new address without any origin_id (manual) defp put_address(%Changeset{} = changeset, _attrs) do cast_assoc(changeset, :physical_address) end @@ -225,7 +238,7 @@ defmodule Mobilizon.Events.Event do %Changeset{changes: %{draft: true}} = changeset, _action ) do - cast_embed(changeset, :participant_stats) + put_embed(changeset, :participant_stats, %{creator: 0}) end # Created with any other value: publish diff --git a/lib/mobilizon_web/resolvers/address.ex b/lib/mobilizon_web/resolvers/address.ex index 3754000a1..47d9f8475 100644 --- a/lib/mobilizon_web/resolvers/address.ex +++ b/lib/mobilizon_web/resolvers/address.ex @@ -3,7 +3,6 @@ defmodule MobilizonWeb.Resolvers.Address do Handles the comment-related GraphQL calls """ require Logger - alias Mobilizon.Addresses alias Mobilizon.Addresses.Address alias Mobilizon.Service.Geospatial @@ -11,26 +10,18 @@ defmodule MobilizonWeb.Resolvers.Address do Search an address """ @spec search(map(), map(), map()) :: {:ok, list(Address.t())} - def search(_parent, %{query: query, page: _page, limit: _limit}, %{context: %{ip: ip}}) do - country = ip |> Geolix.lookup() |> Map.get(:country, nil) + def search(_parent, %{query: query, locale: locale, page: _page, limit: _limit}, %{ + context: %{ip: ip} + }) do + geolix = Geolix.lookup(ip) - local_addresses = Task.async(fn -> Addresses.search_addresses(query, country: country) end) + country_code = + case geolix do + %{country: %{iso_code: country_code}} -> String.downcase(country_code) + _ -> nil + end - remote_addresses = Task.async(fn -> Geospatial.service().search(query) end) - - addresses = Task.await(local_addresses) ++ Task.await(remote_addresses) - - # If we have results with same origin_id than those locally saved, don't return them - addresses = - Enum.reduce(addresses, %{}, fn address, addresses -> - if Map.has_key?(addresses, address.origin_id) && !is_nil(address.url) do - addresses - else - Map.put(addresses, address.origin_id, address) - end - end) - - addresses = Map.values(addresses) + addresses = Geospatial.service().search(query, lang: locale, country_code: country_code) {:ok, addresses} end @@ -39,15 +30,12 @@ defmodule MobilizonWeb.Resolvers.Address do Reverse geocode some coordinates """ @spec reverse_geocode(map(), map(), map()) :: {:ok, list(Address.t())} - def reverse_geocode(_parent, %{longitude: longitude, latitude: latitude}, %{context: %{ip: ip}}) do - country = ip |> Geolix.lookup() |> Map.get(:country, nil) - - local_addresses = - Task.async(fn -> Addresses.reverse_geocode(longitude, latitude, country: country) end) - - remote_addresses = Task.async(fn -> Geospatial.service().geocode(longitude, latitude) end) - - addresses = Task.await(local_addresses) ++ Task.await(remote_addresses) + def reverse_geocode( + _parent, + %{longitude: longitude, latitude: latitude, zoom: zoom, locale: locale}, + _context + ) do + addresses = Geospatial.service().geocode(longitude, latitude, lang: locale, zoom: zoom) {:ok, addresses} end diff --git a/lib/mobilizon_web/resolvers/config.ex b/lib/mobilizon_web/resolvers/config.ex index dc762c1e2..3f13c968b 100644 --- a/lib/mobilizon_web/resolvers/config.ex +++ b/lib/mobilizon_web/resolvers/config.ex @@ -4,16 +4,35 @@ defmodule MobilizonWeb.Resolvers.Config do """ alias Mobilizon.Config + alias Geolix.Adapter.MMDB2.Record.{Country, Location} @doc """ Gets config. """ - def get_config(_parent, _params, _context) do + def get_config(_parent, _params, %{ + context: %{ip: ip} + }) do + geolix = Geolix.lookup(ip) + + country_code = + case geolix.city do + %{country: %Country{iso_code: country_code}} -> String.downcase(country_code) + _ -> nil + end + + location = + case geolix.city do + %{location: %Location{} = location} -> Map.from_struct(location) + _ -> nil + end + {:ok, %{ name: Config.instance_name(), registrations_open: Config.instance_registrations_open?(), - description: Config.instance_description() + description: Config.instance_description(), + location: location, + country_code: country_code }} end end diff --git a/lib/mobilizon_web/schema/address.ex b/lib/mobilizon_web/schema/address.ex index da2dbc5ca..aea2a8156 100644 --- a/lib/mobilizon_web/schema/address.ex +++ b/lib/mobilizon_web/schema/address.ex @@ -13,6 +13,7 @@ defmodule MobilizonWeb.Schema.AddressType do field(:region, :string) field(:country, :string) field(:description, :string) + field(:type, :string) field(:url, :string) field(:id, :id) field(:origin_id, :string) @@ -38,6 +39,7 @@ defmodule MobilizonWeb.Schema.AddressType do field(:country, :string) field(:description, :string) field(:url, :string) + field(:type, :string) field(:id, :id) field(:origin_id, :string) end @@ -46,6 +48,7 @@ defmodule MobilizonWeb.Schema.AddressType do @desc "Search for an address" field :search_address, type: list_of(:address) do arg(:query, non_null(:string)) + arg(:locale, :string, default_value: "en") arg(:page, :integer, default_value: 1) arg(:limit, :integer, default_value: 10) @@ -56,6 +59,8 @@ defmodule MobilizonWeb.Schema.AddressType do field :reverse_geocode, type: list_of(:address) do arg(:longitude, non_null(:float)) arg(:latitude, non_null(:float)) + arg(:zoom, :integer, default_value: 15) + arg(:locale, :string, default_value: "en") resolve(&Resolvers.Address.reverse_geocode/3) end diff --git a/lib/mobilizon_web/schema/config.ex b/lib/mobilizon_web/schema/config.ex index e81f807d7..2b4dbecfb 100644 --- a/lib/mobilizon_web/schema/config.ex +++ b/lib/mobilizon_web/schema/config.ex @@ -13,6 +13,14 @@ defmodule MobilizonWeb.Schema.ConfigType do field(:description, :string) field(:registrations_open, :boolean) + field(:country_code, :string) + field(:location, :lonlat) + end + + object :lonlat do + field(:longitude, :float) + field(:latitude, :float) + field(:accuracy_radius, :integer) end object :config_queries do diff --git a/lib/service/geospatial/google_maps.ex b/lib/service/geospatial/google_maps.ex index 1b2960d52..bf0454cf6 100644 --- a/lib/service/geospatial/google_maps.ex +++ b/lib/service/geospatial/google_maps.ex @@ -1,6 +1,6 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do @moduledoc """ - Google Maps [Geocoding service](https://developers.google.com/maps/documentation/geocoding/intro). + Google Maps [Geocoding service](https://developers.google.com/maps/documentation/geocoding/intro). Only works with addresses. Note: Endpoint is hardcoded to Google Maps API. """ @@ -89,7 +89,11 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do url <> "&address=#{args.q}" :geocode -> - url <> "&latlng=#{args.lat},#{args.lon}&result_type=street_address" + zoom = Keyword.get(options, :zoom, 15) + + result_type = if zoom >= 15, do: "street_address", else: "locality" + + url <> "&latlng=#{args.lat},#{args.lon}&result_type=#{result_type}" :place_details -> "https://maps.googleapis.com/maps/api/place/details/json?key=#{api_key}&placeid=#{ diff --git a/lib/service/geospatial/mimirsbrunn.ex b/lib/service/geospatial/mimirsbrunn.ex new file mode 100644 index 000000000..333e9879a --- /dev/null +++ b/lib/service/geospatial/mimirsbrunn.ex @@ -0,0 +1,146 @@ +defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do + @moduledoc """ + [Mimirsbrunn](https://github.com/CanalTP/mimirsbrunn) backend. + + ## Issues + * Has trouble finding POIs. + * Doesn't support zoom level for reverse geocoding + """ + + alias Mobilizon.Addresses.Address + alias Mobilizon.Service.Geospatial.Provider + alias Mobilizon.Config + + require Logger + + @behaviour Provider + + @endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint]) + + @impl Provider + @doc """ + Mimirsbrunn implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`. + """ + @spec geocode(number(), number(), keyword()) :: list(Address.t()) + def geocode(lon, lat, options \\ []) do + user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent()) + headers = [{"User-Agent", user_agent}] + url = build_url(:geocode, %{lon: lon, lat: lat}, options) + Logger.debug("Asking Mimirsbrunn for reverse geocoding with #{url}") + + with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- + HTTPoison.get(url, headers), + {:ok, %{"features" => features}} <- Poison.decode(body) do + process_data(features) + end + end + + @impl Provider + @doc """ + Mimirsbrunn implementation for `c:Mobilizon.Service.Geospatial.Provider.search/2`. + """ + @spec search(String.t(), keyword()) :: list(Address.t()) + def search(q, options \\ []) do + user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent()) + headers = [{"User-Agent", user_agent}] + url = build_url(:search, %{q: q}, options) + Logger.debug("Asking Mimirsbrunn for addresses with #{url}") + + with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- + HTTPoison.get(url, headers), + {:ok, %{"features" => features}} <- Poison.decode(body) do + process_data(features) + end + end + + @spec build_url(atom(), map(), list()) :: String.t() + defp build_url(method, args, options) do + limit = Keyword.get(options, :limit, 10) + lang = Keyword.get(options, :lang, "en") + coords = Keyword.get(options, :coords, nil) + endpoint = Keyword.get(options, :endpoint, @endpoint) + + case method do + :search -> + url = "#{endpoint}/autocomplete?q=#{URI.encode(args.q)}&lang=#{lang}&limit=#{limit}" + if is_nil(coords), do: url, else: url <> "&lat=#{coords.lat}&lon=#{coords.lon}" + + :geocode -> + "#{endpoint}/reverse?lon=#{args.lon}&lat=#{args.lat}" + end + end + + defp process_data(features) do + features + |> Enum.map(fn %{ + "geometry" => %{"coordinates" => coordinates}, + "properties" => %{"geocoding" => geocoding} + } -> + address = process_address(geocoding) + %Address{address | geom: Provider.coordinates(coordinates)} + end) + end + + defp process_address(%{"type" => "poi", "address" => address} = geocoding) do + address = process_address(address) + + %Address{ + address + | type: get_type(geocoding), + origin_id: Map.get(geocoding, "id"), + description: Map.get(geocoding, "name") + } + end + + defp process_address(geocoding) do + %Address{ + country: get_administrative_region(geocoding, "country"), + locality: Map.get(geocoding, "city"), + region: get_administrative_region(geocoding, "region"), + description: Map.get(geocoding, "name"), + postal_code: get_postal_code(geocoding), + street: street_address(geocoding), + origin_id: "mimirsbrunn:" <> Map.get(geocoding, "id"), + type: get_type(geocoding) + } + end + + defp street_address(properties) do + if Map.has_key?(properties, "housenumber") do + Map.get(properties, "housenumber") <> " " <> Map.get(properties, "street") + else + Map.get(properties, "street") + end + end + + defp get_type(%{"type" => type}) when type in ["house", "street", "zone", "address"], do: type + + defp get_type(%{"type" => "poi", "poi_types" => types}) + when is_list(types) and length(types) > 0, + do: hd(types)["id"] + + defp get_type(_), do: nil + + defp get_administrative_region( + %{"administrative_regions" => administrative_regions}, + administrative_level + ) do + Enum.find_value( + administrative_regions, + &process_administrative_region(&1, administrative_level) + ) + end + + defp get_administrative_region(_, _), do: nil + + defp process_administrative_region(%{"zone_type" => "country", "name" => name}, "country"), + do: name + + defp process_administrative_region(%{"zone_type" => "state", "name" => name}, "region"), + do: name + + defp process_administrative_region(_, _), do: nil + + defp get_postal_code(%{"postcode" => nil}), do: nil + defp get_postal_code(%{"postcode" => postcode}), do: postcode |> String.split(";") |> hd() +end diff --git a/lib/service/geospatial/nominatim.ex b/lib/service/geospatial/nominatim.ex index 3f4a6b9c9..b956b1f19 100644 --- a/lib/service/geospatial/nominatim.ex +++ b/lib/service/geospatial/nominatim.ex @@ -27,8 +27,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(url, headers), - {:ok, body} <- Poison.decode(body) do - [process_data(body)] + {:ok, %{"features" => features}} <- Poison.decode(body) do + features |> process_data() |> Enum.filter(& &1) end end @@ -45,8 +45,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(url, headers), - {:ok, body} <- Poison.decode(body) do - body |> Enum.map(fn entry -> process_data(entry) end) |> Enum.filter(& &1) + {:ok, %{"features" => features}} <- Poison.decode(body) do + features |> process_data() |> Enum.filter(& &1) end end @@ -55,39 +55,53 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do limit = Keyword.get(options, :limit, 10) lang = Keyword.get(options, :lang, "en") endpoint = Keyword.get(options, :endpoint, @endpoint) + country_code = Keyword.get(options, :country_code) + zoom = Keyword.get(options, :zoom) api_key = Keyword.get(options, :api_key, @api_key) url = case method do :search -> - "#{endpoint}/search?format=jsonv2&q=#{URI.encode(args.q)}&limit=#{limit}&accept-language=#{ + "#{endpoint}/search?format=geocodejson&q=#{URI.encode(args.q)}&limit=#{limit}&accept-language=#{ lang - }&addressdetails=1" + }&addressdetails=1&namedetails=1" :geocode -> - "#{endpoint}/reverse?format=jsonv2&lat=#{args.lat}&lon=#{args.lon}&addressdetails=1" + url = + "#{endpoint}/reverse?format=geocodejson&lat=#{args.lat}&lon=#{args.lon}&accept-language=#{ + lang + }&addressdetails=1&namedetails=1" + + if is_nil(zoom), do: url, else: url <> "&zoom=#{zoom}" end + url = if is_nil(country_code), do: url, else: "#{url}&countrycodes=#{country_code}" if is_nil(api_key), do: url, else: url <> "&key=#{api_key}" end - @spec process_data(map()) :: Address.t() - defp process_data(%{"address" => address} = body) do - %Address{ - country: Map.get(address, "country"), - locality: Map.get(address, "city"), - region: Map.get(address, "state"), - description: description(body), - geom: [Map.get(body, "lon"), Map.get(body, "lat")] |> Provider.coordinates(), - postal_code: Map.get(address, "postcode"), - street: street_address(address), - origin_id: "osm:" <> to_string(Map.get(body, "osm_id")) - } - rescue - error in ArgumentError -> - Logger.warn(inspect(error)) + defp process_data(features) do + features + |> Enum.map(fn %{ + "geometry" => %{"coordinates" => coordinates}, + "properties" => %{"geocoding" => geocoding} + } -> + address = process_address(geocoding) + %Address{address | geom: Provider.coordinates(coordinates)} + end) + end - nil + defp process_address(geocoding) do + %Address{ + country: Map.get(geocoding, "country"), + locality: + Map.get(geocoding, "city") || Map.get(geocoding, "town") || Map.get(geocoding, "county"), + region: Map.get(geocoding, "state"), + description: description(geocoding), + postal_code: Map.get(geocoding, "postcode"), + type: Map.get(geocoding, "type"), + street: street_address(geocoding), + origin_id: "nominatim:" <> to_string(Map.get(geocoding, "osm_id")) + } end @spec street_address(map()) :: String.t() @@ -97,8 +111,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do Map.has_key?(body, "road") -> Map.get(body, "road") - Map.has_key?(body, "road") -> - Map.get(body, "road") + Map.has_key?(body, "street") -> + Map.get(body, "street") Map.has_key?(body, "pedestrian") -> Map.get(body, "pedestrian") @@ -107,7 +121,7 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do "" end - Map.get(body, "house_number", "") <> " " <> road + Map.get(body, "housenumber", "") <> " " <> road end @address29_classes ["amenity", "shop", "tourism", "leisure"] @@ -115,14 +129,16 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do @spec description(map()) :: String.t() defp description(body) do - if !Map.has_key?(body, "display_name") do - Logger.warn("Address has no display name") - raise ArgumentError, message: "Address has no display_name" - end - - description = Map.get(body, "display_name") + description = Map.get(body, "name") address = Map.get(body, "address") + description = + if Map.has_key?(body, "namedetails"), + do: body |> Map.get("namedetails") |> Map.get("name", description), + else: description + + description = if is_nil(description), do: street_address(body), else: description + if (Map.get(body, "category") in @address29_categories or Map.get(body, "class") in @address29_classes) and Map.has_key?(address, "address29") do Map.get(address, "address29") diff --git a/lib/service/geospatial/provider.ex b/lib/service/geospatial/provider.ex index e421df7f5..40d8b8278 100644 --- a/lib/service/geospatial/provider.ex +++ b/lib/service/geospatial/provider.ex @@ -16,6 +16,7 @@ defmodule Mobilizon.Service.Geospatial.Provider do * `:user_agent` User-Agent string to send to the backend. Defaults to `"Mobilizon"` * `:lang` Lang in which to prefer results. Used as a request parameter or through an `Accept-Language` HTTP header. Defaults to `"en"`. + * `:country_code` An ISO 3166 country code. String or `nil` * `:limit` Maximum limit for the number of results returned by the backend. Defaults to `10` * `:api_key` Allows to override the API key (if the backend requires one) set diff --git a/priv/repo/migrations/20191106141051_add_type_to_addresses.exs b/priv/repo/migrations/20191106141051_add_type_to_addresses.exs new file mode 100644 index 000000000..cffee79cf --- /dev/null +++ b/priv/repo/migrations/20191106141051_add_type_to_addresses.exs @@ -0,0 +1,9 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddTypeToAddresses do + use Ecto.Migration + + def change do + alter table(:addresses) do + add(:type, :string) + end + end +end diff --git a/schema.graphql b/schema.graphql index ec149f147..1dea5d5cd 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,5 +1,5 @@ # source: http://localhost:4000/api -# timestamp: Wed Nov 06 2019 12:50:45 GMT+0100 (Central European Standard Time) +# timestamp: Fri Nov 08 2019 17:20:47 GMT+0100 (Central European Standard Time) schema { query: RootQueryType @@ -131,6 +131,7 @@ type Address { """The address's street name (with number)""" street: String + type: String url: String } @@ -150,6 +151,7 @@ input AddressInput { """The address's street name (with number)""" street: String + type: String url: String } @@ -187,7 +189,9 @@ enum CommentVisibility { """A config object""" type Config { + countryCode: String description: String + location: Lonlat name: String registrationsOpen: Boolean } @@ -629,6 +633,12 @@ type Login { user: User! } +type Lonlat { + accuracyRadius: Int + latitude: Float + longitude: Float +} + """ Represents a member of a group @@ -1171,7 +1181,7 @@ type RootQueryType { reverseGeocode(latitude: Float!, longitude: Float!): [Address] """Search for an address""" - searchAddress(limit: Int = 10, page: Int = 1, query: String!): [Address] + searchAddress(limit: Int = 10, locale: String = "en", page: Int = 1, query: String!): [Address] """Search events""" searchEvents(limit: Int = 10, page: Int = 1, search: String!): Events diff --git a/test/fixtures/vcr_cassettes/geospatial/nominatim/geocode.json b/test/fixtures/vcr_cassettes/geospatial/nominatim/geocode.json index d8513a89d..ad9beef81 100644 --- a/test/fixtures/vcr_cassettes/geospatial/nominatim/geocode.json +++ b/test/fixtures/vcr_cassettes/geospatial/nominatim/geocode.json @@ -2,17 +2,19 @@ { "request": { "body": "", - "headers": [], + "headers": { + "User-Agent": "Test instance mobilizon.test - Mobilizon 1.0.0-beta.1" + }, "method": "get", "options": [], "request_body": "", - "url": "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=45.751718&lon=4.842569&addressdetails=1" + "url": "https://nominatim.openstreetmap.org/reverse?format=geocodejson&lat=45.751718&lon=4.842569&accept-language=en&addressdetails=1&namedetails=1" }, "response": { "binary": false, - "body": "{\"place_id\":41453794,\"licence\":\"Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright\",\"osm_type\":\"node\",\"osm_id\":3078260611,\"lat\":\"45.7517141\",\"lon\":\"4.8425657\",\"place_rank\":30,\"category\":\"place\",\"type\":\"house\",\"importance\":\"0\",\"addresstype\":\"place\",\"name\":null,\"display_name\":\"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Circonscription départementale du Rhône, Auvergne-Rhône-Alpes, France métropolitaine, 69007, France\",\"address\":{\"house_number\":\"10\",\"road\":\"Rue Jangot\",\"suburb\":\"La Guillotière\",\"city_district\":\"Lyon 7e Arrondissement\",\"city\":\"Lyon\",\"county\":\"Lyon\",\"state_district\":\"Circonscription départementale du Rhône\",\"state\":\"Auvergne-Rhône-Alpes\",\"country\":\"France\",\"postcode\":\"69007\",\"country_code\":\"fr\"},\"boundingbox\":[\"45.7516141\",\"45.7518141\",\"4.8424657\",\"4.8426657\"]}", + "body": "{\"type\":\"FeatureCollection\",\"geocoding\":{\"version\":\"0.1.0\",\"attribution\":\"Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright\",\"licence\":\"ODbL\",\"query\":\"45.751718,4.842569\"},\"features\":[{\"type\":\"Feature\",\"properties\":{\"geocoding\":{\"place_id\":41453794,\"osm_type\":\"node\",\"osm_id\":3078260611,\"type\":\"house\",\"accuracy\":0,\"label\":\"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Departemental constituency of Rhône, Auvergne-Rhône-Alpes, Metropolitan France, 69007, France\",\"name\":null,\"housenumber\":\"10\",\"street\":\"Rue Jangot\",\"postcode\":\"69007\",\"city\":\"Lyon\",\"county\":\"Lyon\",\"state\":\"Auvergne-Rhône-Alpes\",\"country\":\"France\",\"admin\":{\"level2\":\"France\",\"level3\":\"Metropolitan France\",\"level4\":\"Auvergne-Rhône-Alpes\",\"level5\":\"Departemental constituency of Rhône\",\"level6\":\"Métropole de Lyon\",\"level7\":\"Lyon\",\"level8\":\"Lyon\",\"level9\":\"Lyon 7e Arrondissement\"}}},\"geometry\":{\"type\":\"Point\",\"coordinates\":[4.8425657,45.7517141]}}]}", "headers": { - "Date": "Thu, 14 Mar 2019 10:26:11 GMT", + "Date": "Tue, 12 Nov 2019 12:21:45 GMT", "Server": "Apache/2.4.29 (Ubuntu)", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "OPTIONS,GET", diff --git a/test/fixtures/vcr_cassettes/geospatial/nominatim/search.json b/test/fixtures/vcr_cassettes/geospatial/nominatim/search.json index c41b678cb..395cf11ee 100644 --- a/test/fixtures/vcr_cassettes/geospatial/nominatim/search.json +++ b/test/fixtures/vcr_cassettes/geospatial/nominatim/search.json @@ -2,17 +2,19 @@ { "request": { "body": "", - "headers": [], + "headers": { + "User-Agent": "Test instance mobilizon.test - Mobilizon 1.0.0-beta.1" + }, "method": "get", "options": [], "request_body": "", - "url": "https://nominatim.openstreetmap.org/search?format=jsonv2&q=10%20rue%20Jangot&limit=10&accept-language=en&addressdetails=1" + "url": "https://nominatim.openstreetmap.org/search?format=geocodejson&q=10%20rue%20Jangot&limit=10&accept-language=en&addressdetails=1&namedetails=1" }, "response": { "binary": false, - "body": "[{\"place_id\":41453794,\"licence\":\"Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright\",\"osm_type\":\"node\",\"osm_id\":3078260611,\"boundingbox\":[\"45.7516641\",\"45.7517641\",\"4.8425157\",\"4.8426157\"],\"lat\":\"45.7517141\",\"lon\":\"4.8425657\",\"display_name\":\"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Departemental constituency of Rhône, Auvergne-Rhône-Alpes, Metropolitan France, 69007, France\",\"place_rank\":30,\"category\":\"place\",\"type\":\"house\",\"importance\":0.31100000000000005,\"address\":{\"house_number\":\"10\",\"road\":\"Rue Jangot\",\"suburb\":\"La Guillotière\",\"city_district\":\"Lyon 7e Arrondissement\",\"city\":\"Lyon\",\"county\":\"Lyon\",\"state_district\":\"Departemental constituency of Rhône\",\"state\":\"Auvergne-Rhône-Alpes\",\"country\":\"France\",\"postcode\":\"69007\",\"country_code\":\"fr\"}}]", + "body": "{\"type\":\"FeatureCollection\",\"geocoding\":{\"version\":\"0.1.0\",\"attribution\":\"Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright\",\"licence\":\"ODbL\",\"query\":\"10 rue Jangot\"},\"features\":[{\"type\":\"Feature\",\"properties\":{\"geocoding\":{\"place_id\":41453794,\"osm_type\":\"node\",\"osm_id\":3078260611,\"type\":\"house\",\"label\":\"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Departemental constituency of Rhône, Auvergne-Rhône-Alpes, Metropolitan France, 69007, France\",\"name\":null,\"housenumber\":\"10\",\"street\":\"Rue Jangot\",\"postcode\":\"69007\",\"city\":\"Lyon\",\"county\":\"Lyon\",\"state\":\"Auvergne-Rhône-Alpes\",\"country\":\"France\",\"admin\":{\"level2\":\"France\",\"level3\":\"Metropolitan France\",\"level4\":\"Auvergne-Rhône-Alpes\",\"level5\":\"Departemental constituency of Rhône\",\"level6\":\"Métropole de Lyon\",\"level7\":\"Lyon\",\"level8\":\"Lyon\",\"level9\":\"Lyon 7e Arrondissement\"}}},\"geometry\":{\"type\":\"Point\",\"coordinates\":[4.8425657,45.7517141]}}]}", "headers": { - "Date": "Thu, 14 Mar 2019 10:24:24 GMT", + "Date": "Tue, 12 Nov 2019 12:21:46 GMT", "Server": "Apache/2.4.29 (Ubuntu)", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "OPTIONS,GET", diff --git a/test/mobilizon/service/geospatial/nominatim_test.exs b/test/mobilizon/service/geospatial/nominatim_test.exs index 04bec7da4..b64bcb935 100644 --- a/test/mobilizon/service/geospatial/nominatim_test.exs +++ b/test/mobilizon/service/geospatial/nominatim_test.exs @@ -29,7 +29,7 @@ defmodule Mobilizon.Service.Geospatial.NominatimTest do assert_called( HTTPoison.get( - "https://nominatim.openstreetmap.org/search?format=jsonv2&q=10%20Rue%20Jangot&limit=5&accept-language=fr&addressdetails=1", + "https://nominatim.openstreetmap.org/search?format=geocodejson&q=10%20Rue%20Jangot&limit=5&accept-language=fr&addressdetails=1&namedetails=1", @httpoison_headers ) ) @@ -38,43 +38,46 @@ defmodule Mobilizon.Service.Geospatial.NominatimTest do test "returns a valid address from search" do use_cassette "geospatial/nominatim/search" do - assert %Address{ - locality: "Lyon", - description: - "10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Departemental constituency of Rhône, Auvergne-Rhône-Alpes, Metropolitan France, 69007, France", - region: "Auvergne-Rhône-Alpes", - country: "France", - postal_code: "69007", - street: "10 Rue Jangot", - geom: %Geo.Point{ - coordinates: {4.8425657, 45.7517141}, - properties: %{}, - srid: 4326 - }, - origin_id: "osm:3078260611" - } == Nominatim.search("10 rue Jangot") |> hd + assert [ + %Address{ + locality: "Lyon", + description: "10 Rue Jangot", + region: "Auvergne-Rhône-Alpes", + country: "France", + postal_code: "69007", + street: "10 Rue Jangot", + geom: %Geo.Point{ + coordinates: {4.8425657, 45.7517141}, + properties: %{}, + srid: 4326 + }, + origin_id: "nominatim:3078260611", + type: "house" + } + ] == Nominatim.search("10 rue Jangot") end end test "returns a valid address from reverse geocode" do use_cassette "geospatial/nominatim/geocode" do - assert %Address{ - locality: "Lyon", - description: - "10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Circonscription départementale du Rhône, Auvergne-Rhône-Alpes, France métropolitaine, 69007, France", - region: "Auvergne-Rhône-Alpes", - country: "France", - postal_code: "69007", - street: "10 Rue Jangot", - geom: %Geo.Point{ - coordinates: {4.8425657, 45.7517141}, - properties: %{}, - srid: 4326 - }, - origin_id: "osm:3078260611" - } == + assert [ + %Address{ + locality: "Lyon", + description: "10 Rue Jangot", + region: "Auvergne-Rhône-Alpes", + country: "France", + postal_code: "69007", + street: "10 Rue Jangot", + geom: %Geo.Point{ + coordinates: {4.8425657, 45.7517141}, + properties: %{}, + srid: 4326 + }, + origin_id: "nominatim:3078260611", + type: "house" + } + ] == Nominatim.geocode(4.842569, 45.751718) - |> hd end end end diff --git a/test/mobilizon_web/resolvers/address_resolver_test.exs b/test/mobilizon_web/resolvers/address_resolver_test.exs index a97d36ec4..47a944c3b 100644 --- a/test/mobilizon_web/resolvers/address_resolver_test.exs +++ b/test/mobilizon_web/resolvers/address_resolver_test.exs @@ -26,11 +26,6 @@ defmodule MobilizonWeb.Resolvers.AddressResolverTest do end test "geocode/3 reverse geocodes coordinates", %{conn: conn} do - address = - insert(:address, - description: "10 rue Jangot, Lyon" - ) - query = """ { reverseGeocode(longitude: -23.01, latitude: 30.01) { @@ -44,7 +39,8 @@ defmodule MobilizonWeb.Resolvers.AddressResolverTest do conn |> get("/api", AbsintheHelpers.query_skeleton(query, "address")) - assert json_response(res, 200)["data"]["reverseGeocode"] == [] + assert json_response(res, 200)["data"]["reverseGeocode"] |> hd |> Map.get("description") == + "Anywhere" query = """ { @@ -60,7 +56,7 @@ defmodule MobilizonWeb.Resolvers.AddressResolverTest do |> get("/api", AbsintheHelpers.query_skeleton(query, "address")) assert json_response(res, 200)["data"]["reverseGeocode"] |> hd |> Map.get("description") == - address.description + "10 rue Jangot, Lyon" end end end diff --git a/test/support/mocks/geospatial_mock.ex b/test/support/mocks/geospatial_mock.ex index 870047466..f0ed1aea2 100644 --- a/test/support/mocks/geospatial_mock.ex +++ b/test/support/mocks/geospatial_mock.ex @@ -9,7 +9,9 @@ defmodule Mobilizon.Service.Geospatial.Mock do @behaviour Provider @impl Provider - def geocode(_lon, _lat, _options \\ []), do: [] + def geocode(_lon, _lat, _options \\ []) + def geocode(45.75, 4.85, _options), do: [%Address{description: "10 rue Jangot, Lyon"}] + def geocode(_lon, _lat, _options), do: [%Address{description: "Anywhere"}] @impl Provider def search(_q, _options \\ []), do: [%Address{description: "10 rue Jangot, Lyon"}]