import WithRender from "./route-form.vue" ; import "./route-form.scss" ; import Vue from "vue" ; import { Component , Prop , Ref , Watch } from "vue-property-decorator" ; import Icon from "../ui/icon/icon" ; import { InjectClient , InjectContext , InjectMapComponents , InjectMapContext } from "../../utils/decorators" ; import { isSearchId , round , splitRouteQuery } from "facilmap-utils" ; import Client , { RouteWithTrackPoints } from "facilmap-client" ; import { showErrorToast } from "../../utils/toasts" ; import { ExportFormat , FindOnMapResult , SearchResult , Type } from "facilmap-types" ; import { MapComponents , MapContext } from "../leaflet-map/leaflet-map" ; import { getMarkerIcon , HashQuery , MarkerLayer , RouteLayer } from "facilmap-leaflet" ; import { getZoomDestinationForRoute , flyTo , normalizeZoomDestination } from "../../utils/zoom" ; import { latLng , LatLng } from "leaflet" ; import draggable from "vuedraggable" ; import RouteMode from "../ui/route-mode/route-mode" ; import DraggableLines from "leaflet-draggable-lines" ; import { throttle } from "lodash" ; import ElevationStats from "../ui/elevation-stats/elevation-stats" ; import ElevationPlot from "../ui/elevation-plot/elevation-plot" ; import { saveAs } from 'file-saver' ; import { isMapResult } from "../../utils/search" ; import { Context } from "../facilmap/facilmap" ; type SearchSuggestion = SearchResult ; type MapSuggestion = FindOnMapResult & { kind : "marker" ; } ; type Suggestion = SearchSuggestion | MapSuggestion ; interface Destination { query : string ; loadingQuery ? : string ; loadingPromise ? : Promise < void > ; loadedQuery ? : string ; searchSuggestions ? : SearchSuggestion [ ] ; mapSuggestions ? : MapSuggestion [ ] ; selectedSuggestion ? : Suggestion ; } function makeCoordDestination ( latlng ) { const disp = round ( latlng . lat , 5 ) + "," + round ( latlng . lng , 5 ) ; let suggestion = { lat : latlng . lat , lon : latlng . lng , display_name : disp , short_name : disp , type : "coordinates" , id : disp } ; return { query : disp , loadingQuery : disp , loadedQuery : disp , selectedSuggestion : suggestion , searchSuggestions : [ suggestion ] } ; } function makeDestination ( query , searchSuggestions ? , mapSuggestions ? , selectedSuggestion ? ) { return { query , loadedQuery : searchSuggestions || mapSuggestions ? query : undefined , searchSuggestions , mapSuggestions : mapSuggestions ?. filter ( ( result ) => result . kind == "marker" ) as MapSuggestion [ ] , selectedSuggestion : selectedSuggestion as MapSuggestion } ; } const startMarkerColour = "00ff00" ; const dragMarkerColour = "ffd700" ; const endMarkerColour = "ff0000" ; function getIcon ( i , length , highlight = false ) { return getMarkerIcon ( i == 0 ? `#${ startMarkerColour }` : i == length - 1 ? `#${ endMarkerColour }` : `#${ dragMarkerColour }` , 35 , undefined , undefined , highlight ) ; } @ WithRender @ Component ( { components : { draggable , ElevationPlot , ElevationStats , Icon , RouteMode } } ) export default class RouteForm extends Vue { @ InjectContext ( ) context ! : Context ; @ InjectMapComponents ( ) mapComponents ! : MapComponents ; @ InjectClient ( ) client ! : Client ; @ InjectMapContext ( ) mapContext ! : MapContext ; @ Ref ( ) submitButton ! : HTMLButtonElement ; @ Prop ( { type : Boolean , default : true } ) active ! : boolean ; @ Prop ( { type : String } ) routeId : string | undefined ; @ Prop ( { type : Boolean , default : true } ) showToolbar ! : boolean ; routeLayer ! : RouteLayer ; draggable ! : DraggableLines ; routeMode = 'car' ; destinations : Destination [ ] = [ { query : "" } , { query : "" } ] ; submittedQuery : string | null = null ; submittedQueryDescription : string | null = null ; routeError : string | null = null ; hoverDestinationIdx : number | null = null ; hoverInsertIdx : number | null = null ; isAdding = false ; isExporting = false ; suggestionMarker : MarkerLayer | undefined ; mounted ( ) { this . routeLayer = new RouteLayer ( this . client , this . routeId , { weight : 7 , opacity : 1 , raised : true } ) . addTo ( this . mapComponents . map ) ; this . routeLayer . on ( "click" , ( e ) => { if ( ! this . active && ! ( e . originalEvent as any ) . ctrlKey ) { this . $emit ( "activate" ) ; } } ) ; this . draggable = new DraggableLines ( this . mapComponents . map , { enableForLayer : false , tempMarkerOptions : ( ) => ( { icon : getMarkerIcon ( `#${ dragMarkerColour }` , 35 ) , pane : "fm-raised-marker" } ) , plusTempMarkerOptions : ( ) => ( { icon : getMarkerIcon ( `#${ dragMarkerColour }` , 35 ) , pane : "fm-raised-marker" } ) , dragMarkerOptions : ( layer , i , length ) => ( { icon : getIcon ( i , length ) , pane : "fm-raised-marker" } ) } ) . enable ( ) ; this . draggable . on ( { insert : ( e ) => { this . destinations . splice ( e . idx , 0 , makeCoordDestination ( e . latlng ) ) ; this . reroute ( false ) ; } , dragstart : ( e ) => { this . hoverDestinationIdx = e . idx ; this . hoverInsertIdx = null ; if ( e . isNew ) this . destinations . splice ( e . idx , 0 , makeCoordDestination ( e . to ) ) ; } , drag : throttle ( ( e ) => { Vue . set ( this . destinations , e . idx , makeCoordDestination ( e . to ) ) ; } , 300 ) , dragend : ( e ) => { Vue . set ( this . destinations , e . idx , makeCoordDestination ( e . to ) ) ; this . reroute ( false ) ; } , remove : ( e ) => { this . hoverDestinationIdx = null ; this . destinations . splice ( e . idx , 1 ) ; this . reroute ( false ) ; } , dragmouseover : ( e ) => { this . destinationMouseOver ( e . idx ) ; } , dragmouseout : ( e ) => { this . destinationMouseOut ( e . idx ) ; } , plusmouseover : ( e ) => { this . hoverInsertIdx = e . idx ; } , plusmouseout : ( e ) => { this . hoverInsertIdx = null ; } , tempmouseover : ( e ) => { this . hoverInsertIdx = e . idx ; } , tempmousemove : ( e ) => { if ( e . idx != this . hoverInsertIdx ) this . hoverInsertIdx = e . idx ; } , tempmouseout : ( e ) => { this . hoverInsertIdx = null ; } } as any ) ; this . handleActiveChange ( this . active ) ; if ( this . routeObj ) { this . destinations = this . routeObj . routePoints . map ( ( point ) => makeCoordDestination ( latLng ( point . lat , point . lon ) ) ) ; this . routeMode = this . routeObj . mode ; } } beforeDestroy ( ) { this . draggable . disable ( ) ; this . routeLayer . remove ( ) ; } get routeObj ( ) : RouteWithTrackPoints | undefined { return this . routeId ? this . client . routes [ this . routeId ] : this . client . route ; } @ Watch ( "active" ) handleActiveChange ( active ) { if ( this . hasRoute ) this . routeLayer . setStyle ( { opacity : active ? 1 : 0.35 , raised : active } ) ; if ( active ) this . draggable . enableForLayer ( this . routeLayer ) ; else this . draggable . disableForLayer ( this . routeLayer ) ; } get hasRoute ( ) : boolean { return ! ! this . routeObj ; } get lineTypes ( ) : Type [ ] { return Object . values ( this . client . types ) . filter ( ( type ) => type . type == "line" ) ; } get hashQuery ( ) : HashQuery | undefined { if ( this . submittedQuery ) { const zoomDest = this . routeObj && getZoomDestinationForRoute ( this . routeObj ) ; return { query : this . submittedQuery , ... ( zoomDest ? normalizeZoomDestination ( this . mapComponents . map , zoomDest ) : { } ) , description : `Route from ${ this . submittedQueryDescription }` } ; } else return undefined ; } @ Watch ( "hashQuery" ) handleHashQueryChange ( hashQuery ) { this . $emit ( "hash-query-change" , hashQuery ) ; } addDestination ( ) { this . destinations . push ( { query : "" } ) ; } removeDestination ( idx ) { if ( this . destinations . length > 2 ) this . destinations . splice ( idx , 1 ) ; } getSelectedSuggestion ( dest ) { if ( dest . selectedSuggestion && [ ... ( dest . searchSuggestions || [ ] ) , ... ( dest . mapSuggestions || [ ] ) ] . includes ( dest . selectedSuggestion ) ) return dest . selectedSuggestion ; else if ( dest . mapSuggestions && dest . mapSuggestions . length > 0 && ( dest . mapSuggestions [ 0 ] . similarity == 1 || ( dest . searchSuggestions || [ ] ) . length == 0 ) ) return dest . mapSuggestions [ 0 ] ; else if ( ( dest . searchSuggestions || [ ] ) . length > 0 ) return dest . searchSuggestions ! [ 0 ] ; else return undefined ; } getSelectedSuggestionId ( dest ) { const sugg = this . getSelectedSuggestion ( dest ) ; if ( ! sugg ) return undefined ; if ( isMapResult ( sugg ) ) return ( sugg . kind == "marker" ? "m" : "l" ) + sugg . id ; else return sugg . id ; } getSelectedSuggestionName ( dest ) { const sugg = this . getSelectedSuggestion ( dest ) ; if ( ! sugg ) return undefined ; if ( isMapResult ( sugg ) ) return sugg . name ; else return sugg . short_name ; } async loadSuggestions ( dest ) { if ( dest . loadingQuery == dest . query . trim ( ) ) { await dest . loadingPromise ; return ; } else if ( dest . loadedQuery == dest . query . trim ( ) ) return ; const idx = this . destinations . indexOf ( dest ) ; this . $bvToast . hide ( `fm${ this . context . id }-route-form-suggestion-error-${ idx }` ) ; Vue . set ( dest , "searchSuggestions" , undefined ) ; Vue . set ( dest , "mapSuggestions" , undefined ) ; Vue . set ( dest , "selectedSuggestion" , undefined ) ; Vue . set ( dest , "loadingQuery" , undefined ) ; Vue . set ( dest , "loadingPromise" , undefined ) ; Vue . set ( dest , "loadedQuery" , undefined ) ; const query = dest . query . trim ( ) ; if ( query != "" ) { dest . loadingQuery = query ; let resolveLoadingPromise = ( ) : void => undefined ; dest . loadingPromise = new Promise ( ( resolve ) => { resolveLoadingPromise = resolve ; } ) ; try { const [ searchResults , mapResults ] = await Promise . all ( [ this . client . find ( { query : query } ) , ( async ( ) => { if ( this . client . padData ) { const m = query . match ( /^m(\d+)$/ ) ; if ( m ) { const marker = await this . client . getMarker ( { id : Number ( m [ 1 ] ) } ) ; return marker ? [ { kind : "marker" as const , similarity : 1 , ... marker } ] : [ ] ; } else return ( await this . client . findOnMap ( { query } ) ) . filter ( ( res ) => res . kind == "marker" ) as MapSuggestion [ ] ; } } ) ( ) ] ) ; if ( query != dest . loadingQuery ) return ; Vue . set ( dest , "loadingQuery" , undefined ) ; Vue . set ( dest , "loadedQuery" , query ) ; Vue . set ( dest , "searchSuggestions" , searchResults ) ; Vue . set ( dest , "mapSuggestions" , mapResults ) ; if ( isSearchId ( query ) && searchResults . length > 0 && searchResults [ 0 ] . display_name ) { if ( dest . query == query ) Vue . set ( dest , "query" , searchResults [ 0 ] . display_name ) ; Vue . set ( dest , "loadedQuery" , searchResults [ 0 ] . display_name ) ; Vue . set ( dest , "selectedSuggestion" , searchResults [ 0 ] ) ; } if ( mapResults ) { const referencedMapResult = mapResults . find ( ( res ) => query == `m${ res . id }` ) ; if ( referencedMapResult ) { if ( dest . query == query ) Vue . set ( dest , "query" , referencedMapResult . name ) ; Vue . set ( dest , "loadedQuery" , referencedMapResult . name ) ; Vue . set ( dest , "selectedSuggestion" , referencedMapResult ) ; } } if ( dest . selectedSuggestion == null ) Vue . set ( dest , "selectedSuggestion" , this . getSelectedSuggestion ( dest ) ) ; } catch ( err ) { if ( query != dest . loadingQuery ) return ; console . warn ( err . stack || err ) ; showErrorToast ( this , `fm${ this . context . id }-route-form-suggestion-error-${ idx }` , `Error finding destination “${ query }”` , err ) ; } finally { resolveLoadingPromise ( ) ; } } } suggestionMouseOver ( suggestion ) { this . suggestionMarker = ( new MarkerLayer ( [ suggestion . lat ! , suggestion . lon ! ] , { highlight : true , marker : { colour : dragMarkerColour , size : 35 , symbol : "" , shape : "drop" } } ) ) . addTo ( this . mapComponents . map ) ; } suggestionMouseOut ( ) { if ( this . suggestionMarker ) { this . suggestionMarker . remove ( ) ; this . suggestionMarker = undefined ; } } suggestionZoom ( suggestion ) { this . mapComponents . map . flyTo ( [ suggestion . lat ! , suggestion . lon ! ] ) ; } destinationMouseOver ( idx ) { const marker = this . routeLayer . _draggableLines ?. dragMarkers [ idx ] ; if ( marker ) { this . hoverDestinationIdx = idx ; marker . setIcon ( getIcon ( idx , this . routeLayer . _draggableLines ! . dragMarkers . length , true ) ) ; } } destinationMouseOut ( idx ) { this . hoverDestinationIdx = null ; const marker = this . routeLayer . _draggableLines ?. dragMarkers [ idx ] ; if ( marker ) { Promise . resolve ( ) . then ( ( ) => { if ( marker [ "_map" ] ) marker . setIcon ( getIcon ( idx , this . routeLayer . _draggableLines ! . dragMarkers . length ) ) ; } ) ; } } getValidationState ( destination ) { if ( this . routeError && destination . query . trim ( ) == '' ) return false ; else if ( destination . loadedQuery && destination . query == destination . loadedQuery && this . getSelectedSuggestion ( destination ) == null ) return false ; else return null ; } async route ( zoom , smooth = true ) { this . reset ( ) ; try { const mode = this . routeMode ; this . submittedQuery = [ this . destinations . map ( ( dest ) => ( this . getSelectedSuggestionId ( dest ) ?? dest . query ) ) . join ( " to " ) , mode ] . join ( " by " ) ; this . submittedQueryDescription = [ this . destinations . map ( ( dest ) => ( this . getSelectedSuggestionName ( dest ) ?? dest . query ) ) . join ( " to " ) , mode ] . join ( " by " ) ; await Promise . all ( this . destinations . map ( ( dest ) => this . loadSuggestions ( dest ) ) ) ; const points = this . destinations . map ( ( dest ) => this . getSelectedSuggestion ( dest ) ) ; this . submittedQuery = [ this . destinations . map ( ( dest ) => ( this . getSelectedSuggestionId ( dest ) ?? dest . query ) ) . join ( " to " ) , mode ] . join ( " by " ) ; this . submittedQueryDescription = [ this . destinations . map ( ( dest ) => ( this . getSelectedSuggestionName ( dest ) ?? dest . query ) ) . join ( " to " ) , mode ] . join ( " by " ) ; if ( points . some ( ( point ) => point == null ) ) { this . routeError = "Some destinations could not be found." ; return ; } const route = await this . client . setRoute ( { routePoints : points . map ( ( point ) => ( { lat : point ! . lat ! , lon : point ! . lon ! } ) ) , mode , routeId : this . routeId } ) ; if ( route && zoom ) flyTo ( this . mapComponents . map , getZoomDestinationForRoute ( route ) , smooth ) ; } catch ( err ) { showErrorToast ( this , `fm${ this . context . id }-route-form-error` , "Error calculating route" , err ) ; } } async reroute ( zoom , smooth = true ) { if ( this . hasRoute ) { await Promise . all ( this . destinations . map ( ( dest ) => this . loadSuggestions ( dest ) ) ) ; const points = this . destinations . map ( ( dest ) => this . getSelectedSuggestion ( dest ) ) ; if ( ! points . some ( ( point ) => point == null ) ) this . route ( zoom , smooth ) ; } } reset ( ) { this . $bvToast . hide ( `fm${ this . context . id }-route-form-error` ) ; this . submittedQuery = null ; this . submittedQueryDescription = null ; this . routeError = null ; if ( this . suggestionMarker ) { this . suggestionMarker . remove ( ) ; this . suggestionMarker = undefined ; } this . client . clearRoute ( { routeId : this . routeId } ) ; } clear ( ) { this . reset ( ) ; this . destinations = [ { query : "" } , { query : "" } ] ; } zoomToRoute ( ) { if ( this . routeObj ) flyTo ( this . mapComponents . map , getZoomDestinationForRoute ( this . routeObj ) ) ; } handleSubmit ( event ) { this . submitButton . focus ( ) ; this . route ( true ) ; } async addToMap ( type ) { this . $bvToast . hide ( `fm${ this . context . id }-route-form-add-error` ) ; this . isAdding = true ; try { const line = await this . client . addLine ( { typeId : type . id , routePoints : this . routeObj ! . routePoints , mode : this . routeObj ! . mode } ) ; this . clear ( ) ; this . mapComponents . selectionHandler . setSelectedItems ( [ { type : "line" , id : line . id } ] , true ) ; } catch ( err ) { showErrorToast ( this , `fm${ this . context . id }-route-form-add-error` , "Error adding line" , err ) ; } finally { this . isAdding = false ; } } async exportRoute ( format ) { this . $bvToast . hide ( `fm${ this . context . id }-route-form-export-error` ) ; this . isExporting = true ; try { const exported = await this . client . exportRoute ( { format } ) ; saveAs ( new Blob ( [ exported ] , { type : "application/gpx+xml" } ) , "FacilMap route.gpx" ) ; } catch ( err ) { showErrorToast ( this , `fm${ this . context . id }-route-form-export-error` , "Error exporting route" , err ) ; } finally { this . isExporting = false ; } } setQuery ( query , zoom = true , smooth = true ) { this . clear ( ) ; const split = splitRouteQuery ( query ) ; this . destinations = split . queries . map ( ( query ) => ( { query } ) ) ; while ( this . destinations . length < 2 ) this . destinations . push ( { query : "" } ) ; this . routeMode = split . mode ?? "car" ; this . route ( zoom , smooth ) ; } setFrom ( ... args ) { Vue . set ( this . destinations , 0 , makeDestination ( ... args ) ) ; this . reroute ( true ) ; } addVia ( ... args ) { this . destinations . splice ( this . destinations . length - 1 , 0 , makeDestination ( ... args ) ) ; this . reroute ( true ) ; } setTo ( ... args ) { Vue . set ( this . destinations , this . destinations . length - 1 , makeDestination ( ... args ) ) ; this . reroute ( true ) ; } }