Introduce Mimirsbrunn geocoder and improve addresses & maps

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-11-08 19:37:14 +01:00
parent 0e7cf89492
commit c599a47d58
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
36 changed files with 940 additions and 267 deletions

View File

@ -137,6 +137,9 @@ config :mobilizon, Mobilizon.Service.Geospatial.GoogleMaps,
config :mobilizon, Mobilizon.Service.Geospatial.MapQuest, config :mobilizon, Mobilizon.Service.Geospatial.MapQuest,
api_key: System.get_env("GEOSPATIAL_MAP_QUEST_API_KEY") || nil 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, config :mobilizon, Oban,
repo: Mobilizon.Storage.Repo, repo: Mobilizon.Storage.Repo,
prune: {:maxlen, 10_000}, prune: {:maxlen, 10_000},

View File

@ -52,7 +52,7 @@ config :mobilizon, MobilizonWeb.Endpoint,
# Do not include metadata nor timestamps in development logs # Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n", level: :debug 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 # Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive. # in production as building large stacktraces may be expensive.

View File

@ -26,6 +26,7 @@
"graphql-tag": "^2.10.1", "graphql-tag": "^2.10.1",
"intersection-observer": "^0.7.0", "intersection-observer": "^0.7.0",
"leaflet": "^1.4.0", "leaflet": "^1.4.0",
"leaflet.locatecontrol": "^0.68.0",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"register-service-worker": "^1.6.2", "register-service-worker": "^1.6.2",
@ -44,6 +45,7 @@
"devDependencies": { "devDependencies": {
"@types/chai": "^4.2.3", "@types/chai": "^4.2.3",
"@types/leaflet": "^1.5.2", "@types/leaflet": "^1.5.2",
"@types/leaflet.locatecontrol": "^0.60.7",
"@types/lodash": "^4.14.141", "@types/lodash": "^4.14.141",
"@types/mocha": "^5.2.6", "@types/mocha": "^5.2.6",
"@vue/cli-plugin-babel": "^4.0.3", "@vue/cli-plugin-babel": "^4.0.3",

View File

@ -1,125 +1,242 @@
<template> <template>
<div> <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 <b-autocomplete
:data="data" :data="data"
v-model="queryText" v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')" :placeholder="$t('e.g. 10 Rue Jangot')"
field="description" field="fullName"
:loading="isFetching" :loading="isFetching"
@typing="getAsyncData" @typing="getAsyncData"
icon="map-marker" icon="map-marker"
@select="option => selected = option"> expanded
@select="updateSelected">
<template slot-scope="{option}"> <template slot-scope="{option}">
<b>{{ option.description }}</b><br /> <b-icon :icon="option.poiInfos.poiIcon.icon" />
<i v-if="option.url != null">Local</i> <b>{{ option.poiInfos.name }}</b><br />
<p> <small>{{ option.poiInfos.alternativeName }}</small>
<small>{{ option.street }},&#32; {{ option.postalCode }} {{ option.locality }}</small>
</p>
</template> </template>
<template slot="empty"> <template slot="empty">
<span v-if="queryText.length < 5">{{ $t('Please type at least 5 characters') }}</span> <span v-if="isFetching">{{ $t('Searching') }}</span>
<span v-else-if="isFetching">{{ $t('Searching…') }}</span>
<div v-else class="is-enabled"> <div v-else class="is-enabled">
<span>{{ $t('No results for "{queryText}"', { queryText }) }}</span> <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="addressModalActive = true"> <!-- <p class="control" @click="openNewAddressModal">-->
<button type="button" class="button is-primary">{{ $t('Add') }}</button> <!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
</p> <!-- </p>-->
</div> </div>
</template> </template>
</b-autocomplete> </b-autocomplete>
</b-field> </b-field>
<b-modal :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep"> <div class="map" v-if="selected && selected.geom">
<div class="modal-card" style="width: auto"> <map-leaflet
<header class="modal-card-head"> :coords="selected.geom"
<p class="modal-card-title">{{ $t('Add an address') }}</p> :marker="{ text: [selected.poiInfos.name, selected.poiInfos.alternativeName], icon: selected.poiInfos.poiIcon.icon}"
</header> :updateDraggableMarkerCallback="reverseGeoCode"
<section class="modal-card-body"> :options="{ zoom: mapDefaultZoom }"
<form> :readOnly="false"
<b-field :label="$t('Name')"> />
<b-input aria-required="true" required v-model="selected.description" /> </div>
</b-field> <!-- <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-field :label="$t('Street')">-->
<b-input v-model="selected.street" /> <!-- <b-input v-model="selected.street" />-->
</b-field> <!-- </b-field>-->
<b-field :label="$t('Postal Code')"> <!-- <b-field grouped>-->
<b-input v-model="selected.postalCode" /> <!-- <b-field :label="$t('Postal Code')">-->
</b-field> <!-- <b-input v-model="selected.postalCode" />-->
<!-- </b-field>-->
<b-field :label="$t('Locality')"> <!-- <b-field :label="$t('Locality')">-->
<b-input v-model="selected.locality" /> <!-- <b-input v-model="selected.locality" />-->
</b-field> <!-- </b-field>-->
<!-- </b-field>-->
<b-field :label="$t('Region')"> <!-- <b-field grouped>-->
<b-input v-model="selected.region" /> <!-- <b-field :label="$t('Region')">-->
</b-field> <!-- <b-input v-model="selected.region" />-->
<!-- </b-field>-->
<b-field :label="$t('Country')"> <!-- <b-field :label="$t('Country')">-->
<b-input v-model="selected.country" /> <!-- <b-input v-model="selected.country" />-->
</b-field> <!-- </b-field>-->
</form> <!-- </b-field>-->
</section> <!-- </form>-->
<footer class="modal-card-foot"> <!-- </section>-->
<button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button> <!-- <footer class="modal-card-foot">-->
</footer> <!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>-->
</div> <!-- </footer>-->
</b-modal> <!-- </div>-->
<!-- </b-modal>-->
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { Address, IAddress } from '@/types/address.model'; 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 { Modal } from 'buefy/dist/components/dialog';
import { LatLng } from 'leaflet';
@Component({ @Component({
components: { components: {
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
Modal, Modal,
}, },
}) })
export default class AddressAutoComplete extends Vue { export default class AddressAutoComplete extends Vue {
@Prop({ required: false, default: () => [] }) initialData!: IAddress[]; @Prop({ required: true }) value!: IAddress;
@Prop({ required: false }) value!: IAddress;
data: IAddress[] = this.initialData; data: IAddress[] = [];
selected: IAddress|null = new Address(); selected!: IAddress;
isFetching: boolean = false; isFetching: boolean = false;
queryText: string = this.value && this.value.description || ''; queryText: string = this.value && (new Address(this.value)).fullName || '';
addressModalActive: boolean = false; 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) { async getAsyncData(query) {
if (query.length < 5) { if (!query.length) {
this.data = [];
this.selected = new Address();
return;
}
if (query.length < 3) {
this.data = []; this.data = [];
return; return;
} }
this.isFetching = true; this.isFetching = true;
const result = await this.$apollo.query({ const result = await this.$apollo.query({
query: ADDRESS, query: ADDRESS,
fetchPolicy: 'no-cache', fetchPolicy: 'network-only',
variables: { query }, 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; this.isFetching = false;
} }
// Watch deep because of subproperties updateSelected(option) {
@Watch('selected', { deep: true }) if (option == null) return;
updateSelected() { this.selected = option;
console.log('update selected', this.selected);
this.$emit('input', this.selected); this.$emit('input', this.selected);
} }
resetPopup() { resetPopup() {
this.selected = new Address(); 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> </script>
<style lang="scss"> <style lang="scss">
.autocomplete .dropdown-item.is-disabled .is-enabled { .autocomplete {
opacity: 1 !important; .dropdown-menu {
cursor: auto; z-index: 2000;
}
.dropdown-item.is-disabled {
opacity: 1 !important;
cursor: auto;
}
}
.read-only {
cursor: pointer;
}
.map {
height: 400px;
width: 100%;
} }
</style> </style>

View File

@ -5,40 +5,54 @@
:style="`height: ${mergedOptions.height}; width: ${mergedOptions.width}`" :style="`height: ${mergedOptions.height}; width: ${mergedOptions.width}`"
class="leaflet-map" class="leaflet-map"
:center="[lat, lon]" :center="[lat, lon]"
@click="clickMap"
@update:zoom="updateZoom"
> >
<l-tile-layer <l-tile-layer
url="https://{s}.tile.osm.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png"
attribution="© OpenStreetMap contributors" :attribution="$t('© The OpenStreetMap Contributors')"
> >
</l-tile-layer> </l-tile-layer>
<l-marker :lat-lng="[lat, lon]" > <v-locatecontrol :options="{icon: 'mdi mdi-map-marker'}"/>
<l-popup v-if="popup">{{ popup }}</l-popup> <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-marker>
</l-map> </l-map>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Icon } from 'leaflet'; import { Icon, LatLng, LeafletMouseEvent } from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { Component, Prop, Vue } from 'vue-property-decorator'; 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({ @Component({
components: { LTileLayer, LMap, LMarker, LPopup }, components: { LTileLayer, LMap, LMarker, LPopup, LIcon, 'v-locatecontrol': Vue2LeafletLocateControl },
}) })
export default class Map extends Vue { 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: 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: Object, required: false }) options!: object;
@Prop({ type: Function, required: false, default: () => {} }) updateDraggableMarkerCallback!: Function;
defaultOptions: object = { defaultOptions: {
zoom: Number;
height: String;
width: String;
} = {
zoom: 15, zoom: 15,
height: '100%', height: '100%',
width: '100%', width: '100%',
}; };
zoom = this.defaultOptions.zoom;
mounted() { mounted() {
// this part resolve an issue where the markers would not appear // this part resolve an issue where the markers would not appear
// @ts-ignore // @ts-ignore
@ -51,12 +65,38 @@ export default class Map extends Vue {
}); });
} }
openPopup(event) {
this.$nextTick(() => {
event.target.openPopup();
});
}
get mergedOptions(): object { get mergedOptions(): object {
return { ...this.defaultOptions, ...this.options }; return { ...this.defaultOptions, ...this.options };
} }
get lat() { return this.$props.coords.split(';')[1]; } get lat() { return this.$props.coords.split(';')[1]; }
get lon() { return this.$props.coords.split(';')[0]; } 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -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>

View File

@ -1,20 +1,34 @@
import gql from 'graphql-tag'; import gql from 'graphql-tag';
const $addressFragment = `
id,
description,
geom,
street,
locality,
postalCode,
region,
country,
type,
url,
originId
`;
export const ADDRESS = gql` export const ADDRESS = gql`
query($query:String!) { query($query:String!, $locale: String) {
searchAddress( searchAddress(
query: $query query: $query,
locale: $locale
) { ) {
id, ${$addressFragment}
description, }
geom, }
street, `;
locality,
postalCode, export const REVERSE_GEOCODE = gql`
region, query($latitude: Float!, $longitude: Float!, $zoom: Int, $locale: String) {
country, reverseGeocode(latitude: $latitude, longitude: $longitude, zoom: $zoom, locale: $locale) {
url, ${$addressFragment}
originId
} }
} }
`; `;

View File

@ -5,7 +5,13 @@ query {
config { config {
name, name,
description, description,
registrationsOpen registrationsOpen,
countryCode,
location {
latitude,
longitude,
accuracyRadius
}
} }
} }
`; `;

View File

@ -24,7 +24,9 @@ const physicalAddressQuery = `
region, region,
country, country,
geom, geom,
id type,
id,
originId
`; `;
const tagsQuery = ` const tagsQuery = `

View File

@ -110,6 +110,7 @@
"From the {startDate} to the {endDate}": "From the {startDate} to the {endDate}", "From the {startDate} to the {endDate}": "From the {startDate} to the {endDate}",
"Gather ⋅ Organize ⋅ Mobilize": "Gather ⋅ Organize ⋅ Mobilize", "Gather ⋅ Organize ⋅ Mobilize": "Gather ⋅ Organize ⋅ Mobilize",
"General information": "General information", "General information": "General information",
"Getting location": "Getting location",
"Going as {name}": "Going as {name}", "Going as {name}": "Going as {name}",
"Group List": "Group List", "Group List": "Group List",
"Group full name": "Group full name", "Group full name": "Group full name",
@ -160,7 +161,7 @@
"No events found": "No events found", "No events found": "No events found",
"No group found": "No group found", "No group found": "No group found",
"No groups found": "No groups 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?", "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", "Number of places": "Number of places",
"OK": "OK", "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 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 read the full rules": "Please read the full rules",
"Please refresh the page and retry.": "Please refresh the page and retry.", "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", "Postal Code": "Postal Code",
"Private event": "Private event", "Private event": "Private event",
"Private feeds": "Private feeds", "Private feeds": "Private feeds",
@ -327,5 +327,6 @@
"{count} participants": "No participants yet | One participant | {count} participants", "{count} participants": "No participants yet | One participant | {count} participants",
"{count} requests waiting": "{count} requests waiting", "{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.", "{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"
} }

View File

@ -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 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}", "A validation email was sent to {email}": "Un email de validation a été envoyé à {email}",
"Abandon edition": "Abandonner l'édition", "Abandon edition": "Abandonner l'édition",
"About": "À propos",
"About Mobilizon": "À propos de Mobilizon", "About Mobilizon": "À propos de Mobilizon",
"About this event": "À propos de cet événement", "About this event": "À propos de cet événement",
"About this instance": "À propos de cette instance", "About this instance": "À propos de cette instance",
"Add": "Ajouter", "About": "À propos",
"Add an address": "Ajouter une adresse", "Add an address": "Ajouter une adresse",
"Add some tags": "Ajouter des tags", "Add some tags": "Ajouter des tags",
"Add to my calendar": "Ajouter à mon agenda", "Add to my calendar": "Ajouter à mon agenda",
"Add": "Ajouter",
"Additional comments": "Commentaires additionnels", "Additional comments": "Commentaires additionnels",
"Administration": "Administration", "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.", "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", "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", "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}", "By {name}": "Par {name}",
"Cancel": "Annuler",
"Cancel creation": "Annuler la création", "Cancel creation": "Annuler la création",
"Cancel edition": "Annuler l'édition", "Cancel edition": "Annuler l'édition",
"Cancel my participation request…": "Annuler ma demande de participation…", "Cancel my participation request…": "Annuler ma demande de participation…",
"Cancel my participation…": "Annuler ma 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", "Category": "Catégorie",
"Change": "Modifier",
"Change my identity…": "Changer mon identité…", "Change my identity…": "Changer mon identité…",
"Change my password": "Modifier mon mot de passe", "Change my password": "Modifier mon mot de passe",
"Change password": "Modifier mot de passe", "Change password": "Modifier mot de passe",
"Change": "Modifier",
"Clear": "Effacer", "Clear": "Effacer",
"Click to select": "Cliquez pour sélectionner", "Click to select": "Cliquez pour sélectionner",
"Click to upload": "Cliquez pour uploader", "Click to upload": "Cliquez pour uploader",
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)", "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 on the event page": "Commentaires sur la page de l'événement",
"Comments": "Commentaires",
"Confirm my particpation": "Confirmer ma participation", "Confirm my particpation": "Confirmer ma participation",
"Confirmed: Will happen": "Confirmé : aura lieu", "Confirmed: Will happen": "Confirmé : aura lieu",
"Continue editing": "Continuer l'édition", "Continue editing": "Continuer l'édition",
"Country": "Pays", "Country": "Pays",
"Create": "Créer",
"Create a new event": "Créer un nouvel événement", "Create a new event": "Créer un nouvel événement",
"Create a new group": "Créer un nouveau groupe", "Create a new group": "Créer un nouveau groupe",
"Create a new identity": "Créer une nouvelle identité", "Create a new identity": "Créer une nouvelle identité",
@ -57,16 +56,17 @@
"Create my profile": "Créer mon profil", "Create my profile": "Créer mon profil",
"Create token": "Créer un jeton", "Create token": "Créer un jeton",
"Create, edit or delete events": "Créer, modifier ou supprimer des événements", "Create, edit or delete events": "Créer, modifier ou supprimer des événements",
"Create": "Créer",
"Creator": "Créateur", "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.", "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 and time settings": "Paramètres de date et d'heure",
"Date parameters": "Paramètres de date", "Date parameters": "Paramètres de date",
"Delete": "Supprimer",
"Delete event": "Supprimer un événement", "Delete event": "Supprimer un événement",
"Delete this identity": "Supprimer cette identité", "Delete this identity": "Supprimer cette identité",
"Delete your identity": "Supprimer votre identité", "Delete your identity": "Supprimer votre identité",
"Delete {eventTitle}": "Supprimer {eventTitle}", "Delete {eventTitle}": "Supprimer {eventTitle}",
"Delete {preferredUsername}": "Supprimer {preferredUsername}", "Delete {preferredUsername}": "Supprimer {preferredUsername}",
"Delete": "Supprimer",
"Description": "Description", "Description": "Description",
"Didn't receive the instructions ?": "Vous n'avez pas reçu les instructions ?", "Didn't receive the instructions ?": "Vous n'avez pas reçu les instructions ?",
"Display name": "Nom affiché", "Display name": "Nom affiché",
@ -84,7 +84,6 @@
"Error while communicating with the server.": "Erreur de communication avec le serveur.", "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 saving report.": "Erreur lors de l'enregistrement du signalement.",
"Error while validating account": "Erreur lors de la validation du compte", "Error while validating account": "Erreur lors de la validation du compte",
"Event": "Événement",
"Event already passed": "Événement déjà passé", "Event already passed": "Événement déjà passé",
"Event cancelled": "Événement annulé", "Event cancelled": "Événement annulé",
"Event creation": "Création d'événement", "Event creation": "Création d'événement",
@ -95,6 +94,7 @@
"Event to be confirmed": "Événement à confirmer", "Event to be confirmed": "Événement à confirmer",
"Event {eventTitle} deleted": "Événement {eventTitle} supprimé", "Event {eventTitle} deleted": "Événement {eventTitle} supprimé",
"Event {eventTitle} reported": "Événement {eventTitle} signalé", "Event {eventTitle} reported": "Événement {eventTitle} signalé",
"Event": "Événement",
"Events": "Événements", "Events": "Événements",
"Exclude": "Exclure", "Exclude": "Exclure",
"Explore": "Explorer", "Explore": "Explorer",
@ -102,14 +102,15 @@
"Features": "Fonctionnalités", "Features": "Fonctionnalités",
"Find an address": "Trouver une adresse", "Find an address": "Trouver une adresse",
"Find an instance": "Trouver une instance", "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é ?", "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 lanniversaire entre ami·e·s à une marche pour le climat, aujourdhui, les bonnes raisons de se rassembler sont <b>captées par les géants du web</b>. Comment sorganiser, comment cliquer sur «je participe» sans <b>livrer des données intimes</b> à Facebook ou<b> senfermer</b> dans MeetUp?", "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 lanniversaire entre ami·e·s à une marche pour le climat, aujourdhui, les bonnes raisons de se rassembler sont <b>captées par les géants du web</b>. Comment sorganiser, comment cliquer sur «je participe» sans <b>livrer des données intimes</b> à Facebook ou<b> senfermer</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} 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}", "From the {startDate} to the {endDate}": "Du {startDate} au {endDate}",
"Gather ⋅ Organize ⋅ Mobilize": "Rassembler ⋅ Organiser ⋅ Mobiliser", "Gather ⋅ Organize ⋅ Mobilize": "Rassembler ⋅ Organiser ⋅ Mobiliser",
"General information": "Informations générales", "General information": "Informations générales",
"Getting location": "Récupération de la position",
"Going as {name}": "En tant que {name}", "Going as {name}": "En tant que {name}",
"Group List": "Liste de groupes", "Group List": "Liste de groupes",
"Group full name": "Nom complet du groupe", "Group full name": "Nom complet du groupe",
@ -131,8 +132,8 @@
"Join {instance}, a Mobilizon instance": "Rejoignez {instance}, une instance Mobilizon", "Join {instance}, a Mobilizon instance": "Rejoignez {instance}, une instance Mobilizon",
"Last published event": "Dernier événement publié", "Last published event": "Dernier événement publié",
"Last week": "La semaine dernière", "Last week": "La semaine dernière",
"Learn more": "En apprendre plus",
"Learn more about Mobilizon": "En apprendre plus à propos de Mobilizon", "Learn more about Mobilizon": "En apprendre plus à propos de Mobilizon",
"Learn more": "En apprendre plus",
"Leave event": "Annuler ma participation à l'événement", "Leave event": "Annuler ma participation à l'événement",
"Leaving event \"{title}\"": "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", "Let's create a new common": "Créons un nouveau Common",
@ -142,8 +143,8 @@
"Locality": "Commune", "Locality": "Commune",
"Log in": "Se connecter", "Log in": "Se connecter",
"Log out": "Se déconnecter", "Log out": "Se déconnecter",
"Login": "Se connecter",
"Login on Mobilizon!": "Se connecter sur Mobilizon !", "Login on Mobilizon!": "Se connecter sur Mobilizon !",
"Login": "Se connecter",
"Manage participations": "Gérer les participations", "Manage participations": "Gérer les participations",
"Members": "Membres", "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.", "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 group found": "Aucun groupe trouvé",
"No groups found": "Aucun groupe trouvé", "No groups found": "Aucun groupe trouvé",
"No results for \"{queryText}\"": "Pas de résultats pour « {queryText} »", "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 ?", "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", "Number of places": "Nombre de places",
"OK": "OK", "OK": "OK",
"Old password": "Ancien mot de passe", "Old password": "Ancien mot de passe",
"On {date}": "Le {date}",
"On {date} ending at {endTime}": "Le {date}, se terminant à {endTime}", "On {date} ending at {endTime}": "Le {date}, se terminant à {endTime}",
"On {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}", "On {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}",
"On {date} starting at {startTime}": "Le {date} à partir de {startTime}", "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", "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é)", "Only accessible through link and search (private)": "Uniquement accessibles par lien et la recherche (privé)",
"Opened reports": "Signalements ouverts", "Opened reports": "Signalements ouverts",
"Organized": "Organisés",
"Organized by {name}": "Organisé par {name}", "Organized by {name}": "Organisé par {name}",
"Organized": "Organisés",
"Organizer": "Organisateur", "Organizer": "Organisateur",
"Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.", "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)", "Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)",
@ -184,10 +186,10 @@
"Participate": "Participer", "Participate": "Participer",
"Participation approval": "Validation des participations", "Participation approval": "Validation des participations",
"Participation requested!": "Participation demandée !", "Participation requested!": "Participation demandée !",
"Password": "Mot de passe",
"Password (confirmation)": "Mot de passe (confirmation)", "Password (confirmation)": "Mot de passe (confirmation)",
"Password change": "Changement de mot de passe", "Password change": "Changement de mot de passe",
"Password reset": "Réinitialisation du mot de passe", "Password reset": "Réinitialisation du mot de passe",
"Password": "Mot de passe",
"Past events": "Événements passés", "Past events": "Événements passés",
"Pick an identity": "Choisissez une identité", "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.", "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", "RSS/Atom Feed": "Flux RSS/Atom",
"Read Framasofts statement of intent on the Framablog": "Lire la note dintention de Framasoft sur le Framablog", "Read Framasofts statement of intent on the Framablog": "Lire la note dintention de Framasoft sur le Framablog",
"Region": "Région", "Region": "Région",
"Register": "S'inscrire",
"Register an account on Mobilizon!": "S'inscrire sur Mobilizon !", "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 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.", "Registration is currently closed.": "Les inscriptions sont actuellement fermées.",
"Reject": "Rejetter", "Reject": "Rejetter",
"Rejected": "Rejetés",
"Rejected participations": "Participations rejetées", "Rejected participations": "Participations rejetées",
"Report": "Signaler", "Rejected": "Rejetés",
"Report this event": "Signaler cet événement", "Report this event": "Signaler cet événement",
"Report": "Signaler",
"Requests": "Requêtes", "Requests": "Requêtes",
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation", "Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
"Reset my password": "Réinitialiser mon mot de passe", "Reset my password": "Réinitialiser mon mot de passe",
"Save": "Enregistrer",
"Save draft": "Enregistrer le brouillon", "Save draft": "Enregistrer le brouillon",
"Search": "Rechercher", "Save": "Enregistrer",
"Search events, groups, etc.": "Rechercher des événements, des groupes, etc.", "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…", "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 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", "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 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 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 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 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 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 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.", "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} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s",
"{count} requests waiting": "Une demande en attente|{count} demandes en attente", "{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.", "{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"
} }

View File

@ -1,11 +1,14 @@
import poiIcons from '@/utils/poiIcons';
export interface IAddress { export interface IAddress {
id?: number; id?: string;
description: string; description: string;
street: string; street: string;
locality: string; locality: string;
postalCode: string; postalCode: string;
region: string; region: string;
country: string; country: string;
type: string;
geom?: string; geom?: string;
url?: string; url?: string;
originId?: string; originId?: string;
@ -18,4 +21,86 @@ export class Address implements IAddress {
postalCode: string = ''; postalCode: string = '';
region: string = ''; region: string = '';
street: 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;
}
} }

View File

@ -3,4 +3,10 @@ export interface IConfig {
description: string; description: string;
registrationsOpen: boolean; registrationsOpen: boolean;
countryCode: string;
location: {
latitude: number;
longitude: number;
accuracyRadius: number;
};
} }

View File

@ -1,5 +1,5 @@
import { Actor, IActor, IPerson } from './actor'; 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 { ITag } from '@/types/tag.model';
import { IPicture } from '@/types/picture.model'; import { IPicture } from '@/types/picture.model';
@ -239,7 +239,7 @@ export class EventModel implements IEvent {
this.onlineAddress = hash.onlineAddress; this.onlineAddress = hash.onlineAddress;
this.phoneAddress = hash.phoneAddress; this.phoneAddress = hash.phoneAddress;
this.physicalAddress = hash.physicalAddress; this.physicalAddress = new Address(hash.physicalAddress);
this.participantStats = hash.participantStats; this.participantStats = hash.participantStats;
this.tags = hash.tags; this.tags = hash.tags;

View File

@ -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

61
js/src/utils/poiIcons.ts Normal file
View File

@ -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',
},
};

View File

@ -1,4 +1,3 @@
import {ParticipantRole} from "@/types/event.model";
<template> <template>
<div class="container"> <div class="container">
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
@ -15,7 +14,7 @@ import {ParticipantRole} from "@/types/event.model";
<div class="title-and-informations"> <div class="title-and-informations">
<h1 class="title">{{ event.title }}</h1> <h1 class="title">{{ event.title }}</h1>
<span> <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"> <small v-if="event.participantStats.going > 0 && !actorIsParticipant">
{{ $tc('One person is going', event.participantStats.going, {approved: event.participantStats.going}) }} {{ $tc('One person is going', event.participantStats.going, {approved: event.participantStats.going}) }}
</small> </small>
@ -111,23 +110,27 @@ import {ParticipantRole} from "@/types/event.model";
</p> </p>
</div> </div>
<div class="address-wrapper"> <div class="address-wrapper">
<b-icon icon="map" /> <span v-if="!physicalAddress">
<span v-if="!event.physicalAddress">{{ $t('No address defined') }}</span> <b-icon icon="map" />
<div class="address" v-if="event.physicalAddress"> {{ $t('No address defined') }}
<address> </span>
<span class="addressDescription" :title="event.physicalAddress.description">{{ event.physicalAddress.description }}</span> <div class="address" v-if="physicalAddress">
<span>{{ event.physicalAddress.street }}</span> <span>
<span>{{ event.physicalAddress.postalCode }} {{ event.physicalAddress.locality }}</span> <b-icon :icon="physicalAddress.poiInfos.poiIcon.icon" />
</address> <address>
<span class="map-show-button" @click="showMap = !showMap" v-if="event.physicalAddress && event.physicalAddress.geom"> <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') }} {{ $t('Show map') }}
</span> </span>
</div> </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"> <div class="map">
<map-leaflet <map-leaflet
:coords="event.physicalAddress.geom" :coords="physicalAddress.geom"
:popup="event.physicalAddress.description" :marker="{ text: physicalAddress.fullName, icon: physicalAddress.poiInfos.poiIcon.icon }"
/> />
</div> </div>
</b-modal> </b-modal>
@ -254,7 +257,7 @@ import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import ParticipationButton from '@/components/Event/ParticipationButton.vue'; import ParticipationButton from '@/components/Event/ParticipationButton.vue';
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
import HTML = Mocha.reporters.HTML; import { Address } from '@/types/address.model';
@Component({ @Component({
components: { components: {
@ -596,11 +599,13 @@ export default class Event extends EventMixin {
} }
get eventCapacityOK(): boolean { get eventCapacityOK(): boolean {
if (this.event.draft) return true;
if (!this.event.options.maximumAttendeeCapacity) return true; if (!this.event.options.maximumAttendeeCapacity) return true;
return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant; return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant;
} }
get numberOfPlacesStillAvailable(): number { get numberOfPlacesStillAvailable(): number {
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant; return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant;
} }
@ -611,6 +616,11 @@ export default class Event extends EventMixin {
return null; return null;
} }
} }
get physicalAddress(): Address|null {
if (!this.event.physicalAddress) return null;
return new Address(this.event.physicalAddress);
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -664,25 +674,33 @@ export default class Event extends EventMixin {
cursor: pointer; cursor: pointer;
} }
address { span:first-child {
font-style: normal;
flex-wrap: wrap;
display: flex; display: flex;
justify-content: flex-start;
span.addressDescription { span.icon {
text-overflow: ellipsis; align-self: center;
white-space: nowrap;
flex: 1 0 auto;
min-width: 100%;
max-width: 4rem;
overflow: hidden;
} }
:not(.addressDescription) { address {
color: rgba(46, 62, 72, .6); font-style: normal;
flex: 1; flex-wrap: wrap;
min-width: 100%; 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%;
}
} }
} }
} }

View File

@ -1,7 +1,7 @@
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { ApolloLink, Observable } from 'apollo-link'; 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 { onError } from 'apollo-link-error';
import { createLink } from 'apollo-absinthe-upload-link'; import { createLink } from 'apollo-absinthe-upload-link';
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint'; import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint';
@ -132,6 +132,13 @@ const link = authMiddleware
const cache = new InMemoryCache({ const cache = new InMemoryCache({
fragmentMatcher, fragmentMatcher,
dataIdFromObject: object => {
if (object.__typename === 'Address') {
// @ts-ignore
return object.origin_id;
}
return defaultDataIdFromObject(object);
},
}); });
const apolloClient = new ApolloClient({ const apolloClient = new ApolloClient({

View File

@ -927,7 +927,14 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A== 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" version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.5.5.tgz#006c0aa89c4b5e62941717fa71a09e846423536c" resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.5.5.tgz#006c0aa89c4b5e62941717fa71a09e846423536c"
integrity sha512-Eyh1LMmW4OFgafL6rjLyGkMqFS5IzgwWHMSgTKbrsvwLjLaWH8Ae8CV5liRe8HSM731oOVDwAMIZgg9P0SO9tg== integrity sha512-Eyh1LMmW4OFgafL6rjLyGkMqFS5IzgwWHMSgTKbrsvwLjLaWH8Ae8CV5liRe8HSM731oOVDwAMIZgg9P0SO9tg==
@ -7404,6 +7411,11 @@ lcid@^2.0.0:
dependencies: dependencies:
invert-kv "^2.0.0" 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: leaflet@^1.4.0:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.5.1.tgz#9afb9d963d66c870066b1342e7a06f92840f46bf" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.5.1.tgz#9afb9d963d66c870066b1342e7a06f92840f46bf"

View File

@ -17,6 +17,7 @@ defmodule Mobilizon.Addresses.Address do
geom: Geo.PostGIS.Geometry.t(), geom: Geo.PostGIS.Geometry.t(),
postal_code: String.t(), postal_code: String.t(),
street: String.t(), street: String.t(),
type: String.t(),
url: String.t(), url: String.t(),
origin_id: String.t(), origin_id: String.t(),
events: [Event.t()] events: [Event.t()]
@ -31,7 +32,8 @@ defmodule Mobilizon.Addresses.Address do
:region, :region,
:postal_code, :postal_code,
:street, :street,
:origin_id :origin_id,
:type
] ]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@ -43,6 +45,7 @@ defmodule Mobilizon.Addresses.Address do
field(:geom, Geo.PostGIS.Geometry) field(:geom, Geo.PostGIS.Geometry)
field(:postal_code, :string) field(:postal_code, :string)
field(:street, :string) field(:street, :string)
field(:type, :string)
field(:url, :string) field(:url, :string)
field(:origin_id, :string) field(:origin_id, :string)

View File

@ -28,6 +28,7 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.Media alias Mobilizon.Media
alias Mobilizon.Media.Picture alias Mobilizon.Media.Picture
alias Mobilizon.Mention alias Mobilizon.Mention
alias Mobilizon.Storage.Repo
alias MobilizonWeb.Endpoint alias MobilizonWeb.Endpoint
alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Router.Helpers, as: Routes
@ -105,7 +106,7 @@ defmodule Mobilizon.Events.Event do
embeds_one(:participant_stats, EventParticipantStats, on_replace: :update) embeds_one(:participant_stats, EventParticipantStats, on_replace: :update)
belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id) belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id)
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_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) belongs_to(:picture, Picture, on_replace: :update)
has_many(:tracks, Track) has_many(:tracks, Track)
has_many(:sessions, Session) has_many(:sessions, Session)
@ -194,11 +195,23 @@ defmodule Mobilizon.Events.Event do
put_assoc(changeset, :physical_address, address) put_assoc(changeset, :physical_address, address)
_ -> _ ->
changeset cast_assoc(changeset, :physical_address)
end end
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 defp put_address(%Changeset{} = changeset, _attrs) do
cast_assoc(changeset, :physical_address) cast_assoc(changeset, :physical_address)
end end
@ -225,7 +238,7 @@ defmodule Mobilizon.Events.Event do
%Changeset{changes: %{draft: true}} = changeset, %Changeset{changes: %{draft: true}} = changeset,
_action _action
) do ) do
cast_embed(changeset, :participant_stats) put_embed(changeset, :participant_stats, %{creator: 0})
end end
# Created with any other value: publish # Created with any other value: publish

View File

@ -3,7 +3,6 @@ defmodule MobilizonWeb.Resolvers.Address do
Handles the comment-related GraphQL calls Handles the comment-related GraphQL calls
""" """
require Logger require Logger
alias Mobilizon.Addresses
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Service.Geospatial alias Mobilizon.Service.Geospatial
@ -11,26 +10,18 @@ defmodule MobilizonWeb.Resolvers.Address do
Search an address Search an address
""" """
@spec search(map(), map(), map()) :: {:ok, list(Address.t())} @spec search(map(), map(), map()) :: {:ok, list(Address.t())}
def search(_parent, %{query: query, page: _page, limit: _limit}, %{context: %{ip: ip}}) do def search(_parent, %{query: query, locale: locale, page: _page, limit: _limit}, %{
country = ip |> Geolix.lookup() |> Map.get(:country, nil) 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 = Geospatial.service().search(query, lang: locale, country_code: country_code)
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)
{:ok, addresses} {:ok, addresses}
end end
@ -39,15 +30,12 @@ defmodule MobilizonWeb.Resolvers.Address do
Reverse geocode some coordinates Reverse geocode some coordinates
""" """
@spec reverse_geocode(map(), map(), map()) :: {:ok, list(Address.t())} @spec reverse_geocode(map(), map(), map()) :: {:ok, list(Address.t())}
def reverse_geocode(_parent, %{longitude: longitude, latitude: latitude}, %{context: %{ip: ip}}) do def reverse_geocode(
country = ip |> Geolix.lookup() |> Map.get(:country, nil) _parent,
%{longitude: longitude, latitude: latitude, zoom: zoom, locale: locale},
local_addresses = _context
Task.async(fn -> Addresses.reverse_geocode(longitude, latitude, country: country) end) ) do
addresses = Geospatial.service().geocode(longitude, latitude, lang: locale, zoom: zoom)
remote_addresses = Task.async(fn -> Geospatial.service().geocode(longitude, latitude) end)
addresses = Task.await(local_addresses) ++ Task.await(remote_addresses)
{:ok, addresses} {:ok, addresses}
end end

View File

@ -4,16 +4,35 @@ defmodule MobilizonWeb.Resolvers.Config do
""" """
alias Mobilizon.Config alias Mobilizon.Config
alias Geolix.Adapter.MMDB2.Record.{Country, Location}
@doc """ @doc """
Gets config. 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, {:ok,
%{ %{
name: Config.instance_name(), name: Config.instance_name(),
registrations_open: Config.instance_registrations_open?(), registrations_open: Config.instance_registrations_open?(),
description: Config.instance_description() description: Config.instance_description(),
location: location,
country_code: country_code
}} }}
end end
end end

View File

@ -13,6 +13,7 @@ defmodule MobilizonWeb.Schema.AddressType do
field(:region, :string) field(:region, :string)
field(:country, :string) field(:country, :string)
field(:description, :string) field(:description, :string)
field(:type, :string)
field(:url, :string) field(:url, :string)
field(:id, :id) field(:id, :id)
field(:origin_id, :string) field(:origin_id, :string)
@ -38,6 +39,7 @@ defmodule MobilizonWeb.Schema.AddressType do
field(:country, :string) field(:country, :string)
field(:description, :string) field(:description, :string)
field(:url, :string) field(:url, :string)
field(:type, :string)
field(:id, :id) field(:id, :id)
field(:origin_id, :string) field(:origin_id, :string)
end end
@ -46,6 +48,7 @@ defmodule MobilizonWeb.Schema.AddressType do
@desc "Search for an address" @desc "Search for an address"
field :search_address, type: list_of(:address) do field :search_address, type: list_of(:address) do
arg(:query, non_null(:string)) arg(:query, non_null(:string))
arg(:locale, :string, default_value: "en")
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
@ -56,6 +59,8 @@ defmodule MobilizonWeb.Schema.AddressType do
field :reverse_geocode, type: list_of(:address) do field :reverse_geocode, type: list_of(:address) do
arg(:longitude, non_null(:float)) arg(:longitude, non_null(:float))
arg(:latitude, 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) resolve(&Resolvers.Address.reverse_geocode/3)
end end

View File

@ -13,6 +13,14 @@ defmodule MobilizonWeb.Schema.ConfigType do
field(:description, :string) field(:description, :string)
field(:registrations_open, :boolean) 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 end
object :config_queries do object :config_queries do

View File

@ -1,6 +1,6 @@
defmodule Mobilizon.Service.Geospatial.GoogleMaps do defmodule Mobilizon.Service.Geospatial.GoogleMaps do
@moduledoc """ @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. Note: Endpoint is hardcoded to Google Maps API.
""" """
@ -89,7 +89,11 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
url <> "&address=#{args.q}" url <> "&address=#{args.q}"
:geocode -> :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 -> :place_details ->
"https://maps.googleapis.com/maps/api/place/details/json?key=#{api_key}&placeid=#{ "https://maps.googleapis.com/maps/api/place/details/json?key=#{api_key}&placeid=#{

View File

@ -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

View File

@ -27,8 +27,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers), HTTPoison.get(url, headers),
{:ok, body} <- Poison.decode(body) do {:ok, %{"features" => features}} <- Poison.decode(body) do
[process_data(body)] features |> process_data() |> Enum.filter(& &1)
end end
end end
@ -45,8 +45,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers), HTTPoison.get(url, headers),
{:ok, body} <- Poison.decode(body) do {:ok, %{"features" => features}} <- Poison.decode(body) do
body |> Enum.map(fn entry -> process_data(entry) end) |> Enum.filter(& &1) features |> process_data() |> Enum.filter(& &1)
end end
end end
@ -55,39 +55,53 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
limit = Keyword.get(options, :limit, 10) limit = Keyword.get(options, :limit, 10)
lang = Keyword.get(options, :lang, "en") lang = Keyword.get(options, :lang, "en")
endpoint = Keyword.get(options, :endpoint, @endpoint) 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) api_key = Keyword.get(options, :api_key, @api_key)
url = url =
case method do case method do
:search -> :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 lang
}&addressdetails=1" }&addressdetails=1&namedetails=1"
:geocode -> :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 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}" if is_nil(api_key), do: url, else: url <> "&key=#{api_key}"
end end
@spec process_data(map()) :: Address.t() defp process_data(features) do
defp process_data(%{"address" => address} = body) do features
%Address{ |> Enum.map(fn %{
country: Map.get(address, "country"), "geometry" => %{"coordinates" => coordinates},
locality: Map.get(address, "city"), "properties" => %{"geocoding" => geocoding}
region: Map.get(address, "state"), } ->
description: description(body), address = process_address(geocoding)
geom: [Map.get(body, "lon"), Map.get(body, "lat")] |> Provider.coordinates(), %Address{address | geom: Provider.coordinates(coordinates)}
postal_code: Map.get(address, "postcode"), end)
street: street_address(address), end
origin_id: "osm:" <> to_string(Map.get(body, "osm_id"))
}
rescue
error in ArgumentError ->
Logger.warn(inspect(error))
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 end
@spec street_address(map()) :: String.t() @spec street_address(map()) :: String.t()
@ -97,8 +111,8 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
Map.has_key?(body, "road") -> Map.has_key?(body, "road") ->
Map.get(body, "road") Map.get(body, "road")
Map.has_key?(body, "road") -> Map.has_key?(body, "street") ->
Map.get(body, "road") Map.get(body, "street")
Map.has_key?(body, "pedestrian") -> Map.has_key?(body, "pedestrian") ->
Map.get(body, "pedestrian") Map.get(body, "pedestrian")
@ -107,7 +121,7 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
"" ""
end end
Map.get(body, "house_number", "") <> " " <> road Map.get(body, "housenumber", "") <> " " <> road
end end
@address29_classes ["amenity", "shop", "tourism", "leisure"] @address29_classes ["amenity", "shop", "tourism", "leisure"]
@ -115,14 +129,16 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
@spec description(map()) :: String.t() @spec description(map()) :: String.t()
defp description(body) do defp description(body) do
if !Map.has_key?(body, "display_name") do description = Map.get(body, "name")
Logger.warn("Address has no display name")
raise ArgumentError, message: "Address has no display_name"
end
description = Map.get(body, "display_name")
address = Map.get(body, "address") 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 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(body, "class") in @address29_classes) and Map.has_key?(address, "address29") do
Map.get(address, "address29") Map.get(address, "address29")

View File

@ -16,6 +16,7 @@ defmodule Mobilizon.Service.Geospatial.Provider do
* `:user_agent` User-Agent string to send to the backend. Defaults to `"Mobilizon"` * `: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 * `:lang` Lang in which to prefer results. Used as a request parameter or
through an `Accept-Language` HTTP header. Defaults to `"en"`. 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. * `:limit` Maximum limit for the number of results returned by the backend.
Defaults to `10` Defaults to `10`
* `:api_key` Allows to override the API key (if the backend requires one) set * `:api_key` Allows to override the API key (if the backend requires one) set

View File

@ -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

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api # 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 { schema {
query: RootQueryType query: RootQueryType
@ -131,6 +131,7 @@ type Address {
"""The address's street name (with number)""" """The address's street name (with number)"""
street: String street: String
type: String
url: String url: String
} }
@ -150,6 +151,7 @@ input AddressInput {
"""The address's street name (with number)""" """The address's street name (with number)"""
street: String street: String
type: String
url: String url: String
} }
@ -187,7 +189,9 @@ enum CommentVisibility {
"""A config object""" """A config object"""
type Config { type Config {
countryCode: String
description: String description: String
location: Lonlat
name: String name: String
registrationsOpen: Boolean registrationsOpen: Boolean
} }
@ -629,6 +633,12 @@ type Login {
user: User! user: User!
} }
type Lonlat {
accuracyRadius: Int
latitude: Float
longitude: Float
}
""" """
Represents a member of a group Represents a member of a group
@ -1171,7 +1181,7 @@ type RootQueryType {
reverseGeocode(latitude: Float!, longitude: Float!): [Address] reverseGeocode(latitude: Float!, longitude: Float!): [Address]
"""Search for an 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""" """Search events"""
searchEvents(limit: Int = 10, page: Int = 1, search: String!): Events searchEvents(limit: Int = 10, page: Int = 1, search: String!): Events

View File

@ -2,17 +2,19 @@
{ {
"request": { "request": {
"body": "", "body": "",
"headers": [], "headers": {
"User-Agent": "Test instance mobilizon.test - Mobilizon 1.0.0-beta.1"
},
"method": "get", "method": "get",
"options": [], "options": [],
"request_body": "", "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": { "response": {
"binary": false, "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": { "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)", "Server": "Apache/2.4.29 (Ubuntu)",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS,GET", "Access-Control-Allow-Methods": "OPTIONS,GET",

View File

@ -2,17 +2,19 @@
{ {
"request": { "request": {
"body": "", "body": "",
"headers": [], "headers": {
"User-Agent": "Test instance mobilizon.test - Mobilizon 1.0.0-beta.1"
},
"method": "get", "method": "get",
"options": [], "options": [],
"request_body": "", "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": { "response": {
"binary": false, "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": { "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)", "Server": "Apache/2.4.29 (Ubuntu)",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS,GET", "Access-Control-Allow-Methods": "OPTIONS,GET",

View File

@ -29,7 +29,7 @@ defmodule Mobilizon.Service.Geospatial.NominatimTest do
assert_called( assert_called(
HTTPoison.get( 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 @httpoison_headers
) )
) )
@ -38,43 +38,46 @@ defmodule Mobilizon.Service.Geospatial.NominatimTest do
test "returns a valid address from search" do test "returns a valid address from search" do
use_cassette "geospatial/nominatim/search" do use_cassette "geospatial/nominatim/search" do
assert %Address{ assert [
locality: "Lyon", %Address{
description: locality: "Lyon",
"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", description: "10 Rue Jangot",
region: "Auvergne-Rhône-Alpes", region: "Auvergne-Rhône-Alpes",
country: "France", country: "France",
postal_code: "69007", postal_code: "69007",
street: "10 Rue Jangot", street: "10 Rue Jangot",
geom: %Geo.Point{ geom: %Geo.Point{
coordinates: {4.8425657, 45.7517141}, coordinates: {4.8425657, 45.7517141},
properties: %{}, properties: %{},
srid: 4326 srid: 4326
}, },
origin_id: "osm:3078260611" origin_id: "nominatim:3078260611",
} == Nominatim.search("10 rue Jangot") |> hd type: "house"
}
] == Nominatim.search("10 rue Jangot")
end end
end end
test "returns a valid address from reverse geocode" do test "returns a valid address from reverse geocode" do
use_cassette "geospatial/nominatim/geocode" do use_cassette "geospatial/nominatim/geocode" do
assert %Address{ assert [
locality: "Lyon", %Address{
description: locality: "Lyon",
"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", description: "10 Rue Jangot",
region: "Auvergne-Rhône-Alpes", region: "Auvergne-Rhône-Alpes",
country: "France", country: "France",
postal_code: "69007", postal_code: "69007",
street: "10 Rue Jangot", street: "10 Rue Jangot",
geom: %Geo.Point{ geom: %Geo.Point{
coordinates: {4.8425657, 45.7517141}, coordinates: {4.8425657, 45.7517141},
properties: %{}, properties: %{},
srid: 4326 srid: 4326
}, },
origin_id: "osm:3078260611" origin_id: "nominatim:3078260611",
} == type: "house"
}
] ==
Nominatim.geocode(4.842569, 45.751718) Nominatim.geocode(4.842569, 45.751718)
|> hd
end end
end end
end end

View File

@ -26,11 +26,6 @@ defmodule MobilizonWeb.Resolvers.AddressResolverTest do
end end
test "geocode/3 reverse geocodes coordinates", %{conn: conn} do test "geocode/3 reverse geocodes coordinates", %{conn: conn} do
address =
insert(:address,
description: "10 rue Jangot, Lyon"
)
query = """ query = """
{ {
reverseGeocode(longitude: -23.01, latitude: 30.01) { reverseGeocode(longitude: -23.01, latitude: 30.01) {
@ -44,7 +39,8 @@ defmodule MobilizonWeb.Resolvers.AddressResolverTest do
conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "address")) |> 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 = """ query = """
{ {
@ -60,7 +56,7 @@ defmodule MobilizonWeb.Resolvers.AddressResolverTest do
|> get("/api", AbsintheHelpers.query_skeleton(query, "address")) |> get("/api", AbsintheHelpers.query_skeleton(query, "address"))
assert json_response(res, 200)["data"]["reverseGeocode"] |> hd |> Map.get("description") == assert json_response(res, 200)["data"]["reverseGeocode"] |> hd |> Map.get("description") ==
address.description "10 rue Jangot, Lyon"
end end
end end
end end

View File

@ -9,7 +9,9 @@ defmodule Mobilizon.Service.Geospatial.Mock do
@behaviour Provider @behaviour Provider
@impl 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 @impl Provider
def search(_q, _options \\ []), do: [%Address{description: "10 rue Jangot, Lyon"}] def search(_q, _options \\ []), do: [%Address{description: "10 rue Jangot, Lyon"}]