Source

services/geocoding-service.js

/**
 * @namespace Services
 * @category Services
 * @module geocoding-service
 * */

import api from '@/api'
import { getEnvValue } from '@/services/env-service.js'
import { getNestedValue } from '@/utils/object'
import { formatSeconds } from '@/utils/dates'
import i18n from '@/i18n'
import localforage from 'localforage'
import APIUrls from '@/config/simpliciti-apis.js'

const R = require('ramda')
const autocompleteDefaultProvider = 'osm'
const autocompleteProvidersConfiguration = {
  osm: {
    url:
      process.env.VUE_APP_GEOCODING_PROVIDER_OSM_TEST_URL ||
      'https://osm-geocoding.geosab.eu',
  },
}

export const autocompleteProviders = [
  'osm',
  'simpliciti_v3',
  'simpliciti_v3_google',
]

export const transportTypeOptions = [
  { value: 'car', text: 'geocoding.routing.transport_type.car' },
  { value: 'truck', text: 'geocoding.routing.transport_type.truck' },
]

/**
 * @param placeIdOrPlaceItem
 * @param {*} options i.g {provider:"simpliciti_v3"}
 * https://nominatim.org/release-docs/latest/api/Overview/
 * options.provider="simpliciti_v3" will use OSM proxied though our servers
 */
export async function autocompleteFetchPlaceDetails(
  placeIdOrPlaceItem,
  options = {}
) {
  let addressComponent
  let placeId = placeIdOrPlaceItem.code || placeIdOrPlaceItem
  options.provider = options.provider || autocompleteDefaultProvider
  if (!autocompleteProviders.includes(options.provider)) {
    throw new Error('INVALID_GEOCODING_AUTOCOMPLETE_PROVIDER')
  }
  let query = `/details?place_id=${placeId}&format=json&addressdetails=1`
  let r = null
  if (options.provider === 'simpliciti_v3') {
    r = (
      await api.v3.post(APIUrls.APIV3_CARTO_GEOCODING_PROXY_OSM_NOMINATIM, {
        query,
      })
    ).data
  } else if (options.provider === 'simpliciti_v3_google') {
    query = `/geocode/json?place_id=${placeId}`
    r = (
      await api.v3.post(`${APIUrls.APIV3_CARTO_GEOCODING_PROXY_GOOGLE}`, {
        query,
      })
    ).data
  } else {
    r = fetch(
      `${autocompleteProvidersConfiguration[options.provider]?.url}${query}`
    )
    r = await r
    r = await r.json()
  }

  if (options.provider === 'simpliciti_v3_google') {
    return formateGoogleResult(r.results)
  } else {
    addressComponent = (type) =>
      (r.address.find((o) => o.type === type) || {}).localname || ''
    if (!(placeIdOrPlaceItem.name || options.formatted)) {
      throw new Error(
        `Expected to compute 'formatted' though options.formatted or placeIdOrPlaceItem.name`
      )
    }
  }

  return {
    formatted: placeIdOrPlaceItem.name || options.formatted || `)`,
    streetNumber: addressComponent('house_number'),
    street: addressComponent('residential'),
    neighbourhood: addressComponent('neighbourhood'),
    suburb: addressComponent('suburb'),
    city: addressComponent('city'),
    administrative: addressComponent('administrative'),
    state: addressComponent('state'),
    postcode: addressComponent('postcode'),
    country: addressComponent('country'),
    countryCode: addressComponent('country_code'),
    lng: r.geometry && r.geometry.coordinates && r.geometry.coordinates[0],
    lat: r.geometry && r.geometry.coordinates && r.geometry.coordinates[1],
  }
}

function googleAddressComponentsHasKey(result, key) {
  if (Object.getOwnPropertyDescriptor(result, 'address_components')) {
    let data = result.address_components.filter((element) =>
      element.types.includes(key)
    )
    if (data.length) {
      return data[0].long_name
    }
    return ''
  }
}

function formateGoogleResult(results) {
  if (Array.isArray(results) && results.length) {
    const result = results[0]
    return {
      formatted: result.formatted_address || '',
      streetNumber: googleAddressComponentsHasKey(result, 'street_number'),
      street: googleAddressComponentsHasKey(result, 'route'),
      neighbourhood: googleAddressComponentsHasKey(result, 'administrative'),
      suburb: googleAddressComponentsHasKey(result, 'suburb'),
      city: googleAddressComponentsHasKey(result, 'locality'),
      administrative: googleAddressComponentsHasKey(
        result,
        'administrative_area_level_1'
      ),
      state: googleAddressComponentsHasKey(
        result,
        'administrative_area_level_2'
      ),
      postcode: googleAddressComponentsHasKey(result, 'postal_code'),
      country: googleAddressComponentsHasKey(result, 'country'),
      country_code: googleAddressComponentsHasKey(result, 'country_code'),
      lng:
        result.geometry &&
        result.geometry.location &&
        result.geometry.location.lng,
      lat:
        result.geometry &&
        result.geometry.location &&
        result.geometry.location.lat,
    }
  }
}

/**
 *
 * @param {*} query i.g 129 Rue Andy Warhol
 * @param {*} options i.g {provider:"simpliciti_v3"}
 * https://nominatim.org/release-docs/latest/api/Overview/
 * options.provider="simpliciti_v3" will use OSM proxied though our servers
 */
export async function autocompleteFetchPlaces(query, options = {}) {
  let userCountry = (await localforage.getItem('user_infos')).country || ''
  options.provider = options.provider || autocompleteDefaultProvider
  if (!autocompleteProviders.includes(options.provider)) {
    throw new Error('INVALID_GEOCODING_AUTOCOMPLETE_PROVIDER')
  }
  let r

  let requestQuery = `/?accept-language=${userCountry.toLowerCase()}&q=${query}&format=json&countrycodes=${countryCodes.join(
    ','
  )}`
  if (options.provider === 'simpliciti_v3') {
    r = (
      await api.v3.post(APIUrls.APIV3_CARTO_GEOCODING_PROXY_OSM_NOMINATIM, {
        query: requestQuery,
      })
    ).data
  } else if (options.provider === 'simpliciti_v3_google') {
    requestQuery = `/place/autocomplete/json?input=${query}&language=${userCountry.toLowerCase()}&components=country:${userCountry.toLowerCase()}`
    r = (
      await api.v3.post(`${APIUrls.APIV3_CARTO_GEOCODING_PROXY_GOOGLE}`, {
        query: requestQuery,
      })
    ).data.predictions
  } else {
    r = fetch(
      `${
        autocompleteProvidersConfiguration[options.provider]?.url
      }${requestQuery}`
    )
    r = await r
    r = await r.json()
  }

  return r.map((i) => ({
    name: i.display_name || i.description,
    code: i.place_id,
    osmType: i.osm_type || null,
    osmId: i.osm_id || null,
  }))
}

/**
 * If basemapId is empty (e.g use case for custom base maps such as OSM), do not send basemapId parameter to server (This will avoid API 500 error)
 * @param {*} basemapId
 * @returns
 */
function getBasemapIdParam(basemapId) {
  if (basemapId) {
    return {
      basemapId,
    }
  } else {
    return {}
  }
}

/**
 * API for locate address (form/free mode)
 *
 * @param {*} basemapId
 * @param {*} options
 * @returns
 */
export async function forwardGeocoding(basemapId, options = {}) {
  options.getRawResponse =
    options.getRawResponse === undefined ? false : options.getRawResponse
  if (options.address) {
    return api.v3.post(APIUrls.APIV3_CARTO_GEOCODING_NATURAL, {
      ...getBasemapIdParam(basemapId),
      ...R.pick(['getRawResponse', 'address'], options),
    })
  } else {
    return (
      await api.v3.post(APIUrls.APIV3_CARTO_GEOCODING, {
        ...getBasemapIdParam(basemapId),
        ...R.pick(
          [
            'streetNumber',
            'streetName',
            'city',
            'zipCode',
            'county',
            'region',
            'country',
            'getRawResponse',
          ],
          options
        ),
      })
    ).data
  }
}

/**
 *
 * API for locate address by gps coords
 * *Les coordonnées (longitude, latitude) au format WGS84
 * @param {*} basemapId
 * @param {*} options
 * @returns
 */
export async function reverseGeocoding(basemapId, options = {}) {
  options.latitude = options.lat || options.latitude
  options.longitude = options.lng || options.longitude
  options.getRawResponse =
    options.getRawResponse === undefined ? false : options.getRawResponse
  return (
    await api.v3.post(APIUrls.APIV3_CARTO_GEOCODING_REVERSE, {
      ...getBasemapIdParam(basemapId),
      ...R.pick(['longitude', 'latitude', 'getRawResponse'], options),
    })
  ).data
}

/**
 *
 * API for routing feature (Calcul iti)
 * *Les coordonnées (longitude, latitude) au format WGS84
 * @param {*} basemapId
 * @param {*} options
 * @returns
 */
export async function routingGeocoding(basemapId, options = {}) {
  options.getRawResponse =
    options.getRawResponse === undefined ? false : options.getRawResponse
  let res = await api.v3.post(APIUrls.APIV3_CARTO_ROUTING, {
    ...getBasemapIdParam(basemapId),
    ...R.pick(
      [
        'coordinates',
        'transportType',
        'shortest',
        'fastest',
        'avoidFerries',
        'avoidMotorways',
        'avoidTolls',
        'vehicle',
        'speedPonderation',
        'geocoderOptions',
        'getRawResponse',
      ],
      options
    ),
  })
  res = res.data
  let instructions = (res.instructions || [])
    .map((item, index) =>
      normalizeInstructionItem(item, index, res.instructions)
    )
    .filter((item) => {
      return !!item && item.coordinates.length > 0
    })

  let getMarkerType = (item) => {
    let index = (options.coordinates || []).findIndex(
      (coords) => coords[0] == item.longitude && coords[1] == item.latitude
    )
    if (index === 0) return 'start'
    if (index === (options.coordinates || []).length - 1) return 'end'
    return 'step'
  }
  let markers = res.viaCoordinates
    .filter((item) => item.used)
    .map((item) => ({
      lat: getNestedValue(item, 'latitudeMapmatch'),
      lng: getNestedValue(item, 'longitudeMapmatch'),
      type: getMarkerType(item),
    }))
  return {
    markers,
    instructions,
    instructionsCoordinates: getNestedValue(res, 'linestring.coordinates', []),
    lengthFormatted:
      Math.round(getNestedValue(res, 'length', 0) / 1000, 1) + ' km',
    durationFormatted: formatSeconds(getNestedValue(res, 'duration', 0)),
    averageSpeedFormatted: getNestedValue(res, 'averageSpeed', 0) + ' km/h',
  }
}

/**
 * @description
 * Copied from Geored RoutingHtmlDumper.php
 * @todo Add unit-test
 */
function normalizeInstructionItem(item, itemIndex, instructions) {
  let duration = 0
  instructions.forEach((instructionItem, instructionItemIndex) => {
    if (instructionItemIndex <= itemIndex) {
      duration = duration + instructionItem.duration
    } else {
      return false
    }
  })
  let durationFormatted = formatSeconds(duration)

  let lengthFormatted
  if (item.length > 1000) {
    lengthFormatted = Math.round(item.length / 1000, 1) + ' km'
  } else {
    lengthFormatted = item.length + ' m'
  }

  let nextRoadText = ''
  if (item.nextRoadSign) {
    nextRoadText =
      i18n.t('geocoding.instructions.DIRECTION') +
      '&nbsp;<b>' +
      item.nextRoadSign +
      '</b>'
  } else if (item.nextRoadNumber) {
    nextRoadText =
      i18n.t('geocoding.instructions.ON_ROAD') +
      '&nbsp;<b>' +
      item.nextRoadNumber +
      '</b>'
  } else if (item.nextRoadName) {
    nextRoadText =
      i18n.t('geocoding.instructions.ON_ROAD') +
      '&nbsp;<b>' +
      item.nextRoadName +
      '</b>'
  } else if (item.nextStep) {
    nextRoadText =
      i18n.t('geocoding.instructions.DIRECTION') +
      '&nbsp;<b>' +
      item.nextStep +
      '</b>'
  }

  let whenText = ''
  if (item.maneuver == 'STRAIGHT' || item.maneuver == 'UNKNOWN') {
    whenText = i18n.t('geocoding.instructions.DURING')
  } else {
    whenText = i18n.t('geocoding.instructions.IN')
  }

  let instructionIconCode = ''
  let instructionText
  let prevInstruction = instructions[itemIndex - 1]
    ? instructions[itemIndex - 1]
    : null

  let nextInstruction = instructions[itemIndex + 1]
    ? instructions[itemIndex + 1]
    : {
        subManeuver: '',
      }

  if (
    !!instructions[itemIndex + 1] && //only one instruction for an ENTER-EXIT
    ((item.maneuver == 'ROUNDABOUT_ENTER' &&
      nextInstruction.maneuver == 'ROUNDABOUT_EXIT') ||
      (item.subManeuver == 'ROUNDABOUT_ENTER' &&
        nextInstruction.subManeuver == 'ROUNDABOUT_EXIT'))
  ) {
    return null
  } else if (
    !!prevInstruction && //only one instruction for an ENTER-EXIT
    ((prevInstruction.maneuver == 'ROUNDABOUT_ENTER' &&
      item.maneuver == 'ROUNDABOUT_EXIT') ||
      (prevInstruction.subManeuver == 'ROUNDABOUT_ENTER' &&
        item.subManeuver == 'ROUNDABOUT_EXIT'))
  ) {
    instructionIconCode = 'ROUNDABOUT_' + item.roundaboutExit

    let lengthRA = ''
    if (prevInstruction.length + item.length >= 1000) {
      lengthRA =
        Math.round((prevInstruction.length + item.length) / 1000, 1) + ' km'
    } else {
      lengthRA = prevInstruction.length + item.length + ' m'
    }

    instructionText =
      i18n.t('geocoding.instructions.AT') + ' ' + lengthRA + ', '
    instructionText += i18n.t('geocoding.instructions.ROUNDABOUT_ENTER') + ' '
    instructionText +=
      ' ' +
      i18n.t('geocoding.instructions.ROUNDABOUT_EXIT') +
      ' ' +
      item.roundaboutExit +
      i18n.t('geocoding.instructions.ROUNDABOUT_EXIT_NUMBER')
    instructionText += ' ' + nextRoadText
    let duration_to_nextRA = prevInstruction.duration + item.duration
    instructionText +=
      duration_to_nextRA < 60
        ? ' '
        : ' ' + whenText + ' ' + formatSeconds(duration_to_nextRA) + '.'
  } else if (
    item.maneuver == 'ROUNDABOUT' ||
    item.maneuver == 'ROUNDABOUT_ENTER' ||
    item.subManeuver == 'ROUNDABOUT' ||
    item.subManeuver == 'ROUNDABOUT_ENTER'
  ) {
    instructionIconCode = 'ROUNDABOUT_'.item.roundaboutExit

    instructionText =
      i18n.t('geocoding.instructions.AT') + ' ' + lengthFormatted + ', '
    instructionText += i18n.t('geocoding.instructions.ROUNDABOUT_ENTER') + ' '
    instructionText +=
      ' ' +
      i18n.t('geocoding.instructions.ROUNDABOUT_EXIT') +
      ' ' +
      item.roundaboutExit +
      i18n.t('geocoding.instructions.ROUNDABOUT_EXIT_NUMBER')
    instructionText += ' '.nextRoadText
    instructionText +=
      item.duration < 60
        ? ' '
        : ' ' +
          i18n.t('geocoding.instructions.IN') +
          ' ' +
          formatSeconds(item.duration) +
          '.'
  } else if (
    item.maneuver == 'ROUNDABOUT_EXIT' ||
    item.subManeuver == 'ROUNDABOUT_EXIT'
  ) {
    //instructionIconCode = "ROUNDABOUT_".item.roundaboutExit;
    instructionIconCode = 'ROUNDABOUT_EXIT_' + item.roundaboutExit
    instructionText =
      i18n.t('geocoding.instructions.AT') + ' ' + lengthFormatted + ', '
    instructionText +=
      i18n.t('geocoding.instructions.ROUNDABOUT_EXIT') +
      ' ' +
      i18n.t('geocoding.instructions.AT_EXIT') +
      ' ' +
      item.roundaboutExit +
      i18n.t('geocoding.instructions.ROUNDABOUT_EXIT_NUMBER')
    instructionText += ' '.nextRoadText
  } else if (item.maneuver == 'STOP' || item.subManeuver == 'STOP') {
    instructionText =
      i18n.t('geocoding.instructions.AT') + ' ' + lengthFormatted + ', '
    instructionText += i18n.t('geocoding.instructions.STOP') + ' '
    instructionText +=
      item.duration < 60
        ? ' '
        : ' ' + whenText + ' ' + formatSeconds(item.duration) + '.'
  } else {
    instructionIconCode = item.maneuver
    instructionText =
      i18n.t('geocoding.instructions.AT') + ' ' + lengthFormatted + ', '
    instructionText += i18n.t(`geocoding.instructions.${item.maneuver}`) + ' '
    instructionText +=
      item.subManeuver == '' || item.subManeuver == 'FOLLOW'
        ? ''
        : i18n.t('geocoding.instructions.AND') +
          ' ' +
          i18n.t(`geocoding.instructions.${item.subManeuver}`) +
          ' '
    instructionText += nextRoadText
    instructionText +=
      item.duration < 60
        ? '. '
        : ' ' + whenText + ' ' + formatSeconds(item.duration) + '.'
  }

  return {
    number: itemIndex + 1,
    //durationFormatted: formatSeconds(getNestedValue(item, "duration", 0)),
    //duration: getNestedValue(item, "duration", 0),
    duration,
    durationFormatted,
    //text: getNestedValue(item, "nextStep"),
    text: instructionText,
    maneuver: getNestedValue(item, 'maneuver'),
    coordinates: getNestedValue(item, 'linestring.coordinates', []),
    iconCode: instructionIconCode
      .split('ROUNDABOUT_')
      .join('ROUNDABOUT_EXIT_')
      .split('EXIT_EXIT')
      .join('EXIT'),
  }
}

/**
 * OSM Nominatim can be restricted to certain countries to improve search speed:
 * Africa + France, Belgium, Switzerland, Germany, Netherlands
 */
const countryCodes = [
  'ES',
  'CH',
  'BE',
  'FR',
  'NL',
  'GE',
  'DZ',
  ' AO',
  ' SH',
  ' BJ',
  ' BW',
  ' BF',
  ' BI',
  ' CM',
  ' CV',
  ' CF',
  ' TD',
  ' KM',
  ' CG',
  ' CD',
  ' DJ',
  ' EG',
  ' GQ',
  ' ER',
  ' SZ',
  ' ET',
  ' GA',
  ' GM',
  ' GH',
  ' GN',
  ' GW',
  ' CI',
  ' KE',
  ' LS',
  ' LR',
  ' LY',
  ' MG',
  ' MW',
  ' ML',
  ' MR',
  ' MU',
  ' YT',
  ' MA',
  ' MZ',
  ' NA',
  ' NE',
  ' NG',
  ' ST',
  ' RE',
  ' RW',
  ' ST',
  ' SN',
  ' SC',
  ' SL',
  ' SO',
  ' ZA',
  ' SS',
  ' SH',
  ' SD',
  ' SZ',
  ' TZ',
  ' TG',
  ' TN',
  ' UG',
  ' CD',
  ' ZM',
  ' TZ',
  ' ZW',
]