Introduce Mimirsbrunn geocoder and improve addresses & maps
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
0e7cf89492
commit
c599a47d58
@ -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},
|
||||
|
@ -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.
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
||||
<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 :label="$t('Locality')">
|
||||
<b-input v-model="selected.locality" />
|
||||
</b-field>
|
||||
|
||||
<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 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>
|
||||
<!-- <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 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>-->
|
||||
|
||||
<!-- <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>-->
|
||||
<!-- </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 {
|
||||
.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>
|
||||
|
@ -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>
|
||||
|
47
js/src/components/Map/Vue2LeafletLocateControl.vue
Normal file
47
js/src/components/Map/Vue2LeafletLocateControl.vue
Normal 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>
|
@ -1,10 +1,6 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const ADDRESS = gql`
|
||||
query($query:String!) {
|
||||
searchAddress(
|
||||
query: $query
|
||||
) {
|
||||
const $addressFragment = `
|
||||
id,
|
||||
description,
|
||||
geom,
|
||||
@ -13,8 +9,26 @@ export const ADDRESS = gql`
|
||||
postalCode,
|
||||
region,
|
||||
country,
|
||||
type,
|
||||
url,
|
||||
originId
|
||||
`;
|
||||
|
||||
export const ADDRESS = gql`
|
||||
query($query:String!, $locale: String) {
|
||||
searchAddress(
|
||||
query: $query,
|
||||
locale: $locale
|
||||
) {
|
||||
${$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}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -5,7 +5,13 @@ query {
|
||||
config {
|
||||
name,
|
||||
description,
|
||||
registrationsOpen
|
||||
registrationsOpen,
|
||||
countryCode,
|
||||
location {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracyRadius
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -24,7 +24,9 @@ const physicalAddressQuery = `
|
||||
region,
|
||||
country,
|
||||
geom,
|
||||
id
|
||||
type,
|
||||
id,
|
||||
originId
|
||||
`;
|
||||
|
||||
const tagsQuery = `
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -3,4 +3,10 @@ export interface IConfig {
|
||||
description: string;
|
||||
|
||||
registrationsOpen: boolean;
|
||||
countryCode: string;
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracyRadius: number;
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
22
js/src/utils/.editorconfig
Normal file
22
js/src/utils/.editorconfig
Normal 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
61
js/src/utils/poiIcons.ts
Normal 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',
|
||||
},
|
||||
};
|
@ -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">
|
||||
<span v-if="!physicalAddress">
|
||||
<b-icon icon="map" />
|
||||
<span v-if="!event.physicalAddress">{{ $t('No address defined') }}</span>
|
||||
<div class="address" v-if="event.physicalAddress">
|
||||
{{ $t('No address defined') }}
|
||||
</span>
|
||||
<div class="address" v-if="physicalAddress">
|
||||
<span>
|
||||
<b-icon :icon="physicalAddress.poiInfos.poiIcon.icon" />
|
||||
<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>
|
||||
<span class="addressDescription" :title="physicalAddress.poiInfos.name">{{ physicalAddress.poiInfos.name }}</span>
|
||||
<span>{{ physicalAddress.poiInfos.alternativeName }}</span>
|
||||
</address>
|
||||
<span class="map-show-button" @click="showMap = !showMap" v-if="event.physicalAddress && event.physicalAddress.geom">
|
||||
</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,6 +674,13 @@ export default class Event extends EventMixin {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span:first-child {
|
||||
display: flex;
|
||||
|
||||
span.icon {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
address {
|
||||
font-style: normal;
|
||||
flex-wrap: wrap;
|
||||
@ -686,6 +703,7 @@ export default class Event extends EventMixin {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.map {
|
||||
height: 900px;
|
||||
|
@ -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({
|
||||
|
14
js/yarn.lock
14
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"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
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)
|
||||
country_code =
|
||||
case geolix do
|
||||
%{country: %{iso_code: country_code}} -> String.downcase(country_code)
|
||||
_ -> nil
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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=#{
|
||||
|
146
lib/service/geospatial/mimirsbrunn.ex
Normal file
146
lib/service/geospatial/mimirsbrunn.ex
Normal 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
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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,10 +38,10 @@ defmodule Mobilizon.Service.Geospatial.NominatimTest do
|
||||
|
||||
test "returns a valid address from search" do
|
||||
use_cassette "geospatial/nominatim/search" do
|
||||
assert %Address{
|
||||
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",
|
||||
description: "10 Rue Jangot",
|
||||
region: "Auvergne-Rhône-Alpes",
|
||||
country: "France",
|
||||
postal_code: "69007",
|
||||
@ -51,17 +51,19 @@ defmodule Mobilizon.Service.Geospatial.NominatimTest do
|
||||
properties: %{},
|
||||
srid: 4326
|
||||
},
|
||||
origin_id: "osm:3078260611"
|
||||
} == Nominatim.search("10 rue Jangot") |> hd
|
||||
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{
|
||||
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",
|
||||
description: "10 Rue Jangot",
|
||||
region: "Auvergne-Rhône-Alpes",
|
||||
country: "France",
|
||||
postal_code: "69007",
|
||||
@ -71,10 +73,11 @@ defmodule Mobilizon.Service.Geospatial.NominatimTest do
|
||||
properties: %{},
|
||||
srid: 4326
|
||||
},
|
||||
origin_id: "osm:3078260611"
|
||||
} ==
|
||||
origin_id: "nominatim:3078260611",
|
||||
type: "house"
|
||||
}
|
||||
] ==
|
||||
Nominatim.geocode(4.842569, 45.751718)
|
||||
|> hd
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -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"}]
|
||||
|
Loading…
Reference in New Issue
Block a user