kopia lustrzana https://github.com/FacilMap/facilmap
761 wiersze
24 KiB
Vue
761 wiersze
24 KiB
Vue
<script setup lang="ts">
|
|
import { computed, markRaw, onBeforeUnmount, onMounted, ref, toRaw, watch, watchEffect } from "vue";
|
|
import Icon from "../ui/icon.vue";
|
|
import { decodeRouteQuery, encodeRouteQuery, formatCoordinates, formatDistance, formatRouteMode, formatRouteTime, formatTypeName, isSearchId, normalizeMarkerName } from "facilmap-utils";
|
|
import { useToasts } from "../ui/toasts/toasts.vue";
|
|
import type { ExportFormat, FindOnMapResult, SearchResult } from "facilmap-types";
|
|
import { getMarkerIcon, type HashQuery, MarkerLayer, RouteLayer } from "facilmap-leaflet";
|
|
import { getZoomDestinationForRoute, flyTo, normalizeZoomDestination } from "../../utils/zoom";
|
|
import { latLng, type LatLng } from "leaflet";
|
|
import Draggable from "vuedraggable";
|
|
import RouteMode from "../ui/route-mode.vue";
|
|
import DraggableLines from "leaflet-draggable-lines";
|
|
import { cloneDeep, throttle } from "lodash-es";
|
|
import ElevationStats from "../ui/elevation-stats.vue";
|
|
import ElevationPlot from "../ui/elevation-plot.vue";
|
|
import { isMapResult } from "../../utils/search";
|
|
import type { LineWithTags } from "../../utils/add";
|
|
import vTooltip from "../../utils/tooltip";
|
|
import DropdownMenu from "../ui/dropdown-menu.vue";
|
|
import ZoomToObjectButton from "../ui/zoom-to-object-button.vue";
|
|
import type { RouteDestination } from "../facil-map-context-provider/route-form-tab-context";
|
|
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
|
|
import AddToMapDropdown from "../ui/add-to-map-dropdown.vue";
|
|
import ExportDropdown from "../ui/export-dropdown.vue";
|
|
import { useI18n } from "../../utils/i18n";
|
|
|
|
type SearchSuggestion = SearchResult;
|
|
type MapSuggestion = FindOnMapResult & { kind: "marker" };
|
|
type Suggestion = SearchSuggestion | MapSuggestion;
|
|
|
|
interface Destination extends RouteDestination {
|
|
query: string;
|
|
loadingQuery?: string;
|
|
loadingPromise?: Promise<void>;
|
|
loadedQuery?: string;
|
|
searchSuggestions?: SearchSuggestion[];
|
|
mapSuggestions?: MapSuggestion[];
|
|
selectedSuggestion?: Suggestion;
|
|
}
|
|
|
|
function makeCoordDestination(latlng: LatLng) {
|
|
const disp = formatCoordinates({ lat: latlng.lat, lon: latlng.lng });
|
|
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 }: { query: string; searchSuggestions?: SearchResult[]; mapSuggestions?: FindOnMapResult[]; selectedSuggestion?: SearchResult | FindOnMapResult }): Destination {
|
|
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: number, length: number, highlight = false) {
|
|
return getMarkerIcon(i == 0 ? `#${startMarkerColour}` : i == length - 1 ? `#${endMarkerColour}` : `#${dragMarkerColour}`, 35, undefined, undefined, highlight);
|
|
}
|
|
|
|
const context = injectContextRequired();
|
|
const client = requireClientContext(context);
|
|
const mapContext = requireMapContext(context);
|
|
|
|
const toasts = useToasts();
|
|
const i18n = useI18n();
|
|
|
|
const submitButton = ref<HTMLButtonElement>();
|
|
|
|
const props = withDefaults(defineProps<{
|
|
/** If false, the route layer will be opaque and not draggable. */
|
|
active?: boolean;
|
|
routeId?: string;
|
|
showToolbar?: boolean;
|
|
noClear?: boolean;
|
|
}>(), {
|
|
active: true,
|
|
showToolbar: true
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
activate: [];
|
|
"hash-query-change": [hashQuery: HashQuery | undefined];
|
|
}>();
|
|
|
|
const routeObj = computed(() => props.routeId ? client.value.routes[props.routeId] : client.value.route);
|
|
const hasRoute = computed(() => !!routeObj.value);
|
|
|
|
const routeMode = ref(routeObj.value?.mode ?? "car");
|
|
const destinations = ref<Destination[]>(routeObj.value ? (
|
|
routeObj.value.routePoints.map((point) => makeCoordDestination(latLng(point.lat, point.lon)))
|
|
) : (
|
|
[{ query: "" }, { query: "" }]
|
|
));
|
|
const submittedQuery = ref<{ destinations: Destination[]; mode: string }>();
|
|
const routeError = ref<string>();
|
|
const hoverDestinationIdx = ref<number>();
|
|
const hoverInsertIdx = ref<number>();
|
|
const suggestionMarker = ref<MarkerLayer>();
|
|
|
|
// TODO: Handle client.value change
|
|
const routeLayer = new RouteLayer(client.value, props.routeId, { weight: 7, opacity: 1, raised: true });
|
|
routeLayer.on("click", (e) => {
|
|
if (!props.active && !(e.originalEvent as any).ctrlKey) {
|
|
emit("activate");
|
|
}
|
|
});
|
|
|
|
const draggable = new DraggableLines(mapContext.value.components.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"
|
|
})
|
|
});
|
|
draggable.on({
|
|
insert: (e: any) => {
|
|
destinations.value.splice(e.idx, 0, makeCoordDestination(e.latlng));
|
|
void reroute(false);
|
|
},
|
|
dragstart: (e: any) => {
|
|
hoverDestinationIdx.value = e.idx;
|
|
hoverInsertIdx.value = undefined;
|
|
if (e.isNew)
|
|
destinations.value.splice(e.idx, 0, makeCoordDestination(e.to));
|
|
},
|
|
drag: throttle((e: any) => {
|
|
destinations.value[e.idx] = makeCoordDestination(e.to);
|
|
}, 300),
|
|
dragend: (e: any) => {
|
|
destinations.value[e.idx] = makeCoordDestination(e.to);
|
|
void reroute(false);
|
|
},
|
|
remove: (e: any) => {
|
|
hoverDestinationIdx.value = undefined;
|
|
destinations.value.splice(e.idx, 1);
|
|
void reroute(false);
|
|
},
|
|
dragmouseover: (e: any) => {
|
|
destinationMouseOver(e.idx);
|
|
},
|
|
dragmouseout: (e: any) => {
|
|
destinationMouseOut(e.idx);
|
|
},
|
|
plusmouseover: (e: any) => {
|
|
hoverInsertIdx.value = e.idx;
|
|
},
|
|
plusmouseout: (e: any) => {
|
|
hoverInsertIdx.value = undefined;
|
|
},
|
|
tempmouseover: (e: any) => {
|
|
hoverInsertIdx.value = e.idx;
|
|
},
|
|
tempmousemove: (e: any) => {
|
|
if (e.idx != hoverInsertIdx.value)
|
|
hoverInsertIdx.value = e.idx;
|
|
},
|
|
tempmouseout: (e: any) => {
|
|
hoverInsertIdx.value = undefined;
|
|
}
|
|
} as any);
|
|
|
|
onMounted(() => {
|
|
routeLayer.addTo(mapContext.value.components.map);
|
|
draggable.enable();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
draggable.disable();
|
|
routeLayer.remove();
|
|
});
|
|
|
|
const zoomDestination = computed(() => routeObj.value && getZoomDestinationForRoute(routeObj.value));
|
|
|
|
const hashQuery = computed(() => {
|
|
if (submittedQuery.value) {
|
|
return {
|
|
query: encodeRouteQuery({
|
|
queries: submittedQuery.value.destinations.map((dest) => (getSelectedSuggestionId(dest) ?? dest.query)),
|
|
mode: submittedQuery.value.mode
|
|
}),
|
|
...(zoomDestination.value ? normalizeZoomDestination(mapContext.value.components.map, zoomDestination.value) : {}),
|
|
description: i18n.t("route-form.route-description-outer", {
|
|
inner: i18n.t("route-form.route-description-inner", {
|
|
destinations: submittedQuery.value.destinations.map((dest) => (getSelectedSuggestionName(dest) ?? dest.query)).join(i18n.t("route-form.route-description-inner-joiner")),
|
|
mode: formatRouteMode(submittedQuery.value.mode)
|
|
})
|
|
})
|
|
};
|
|
} else
|
|
return undefined;
|
|
});
|
|
|
|
const destinationsMeta = computed(() => destinations.value.map((destination) => ({
|
|
isInvalid: getValidationState(destination) === false
|
|
})));
|
|
|
|
watchEffect(() => {
|
|
if (hasRoute.value)
|
|
routeLayer.setStyle({ opacity: props.active ? 1 : 0.35, raised: props.active });
|
|
|
|
// Enable dragging after updating the style, since that might re-add the layer to the map
|
|
if (props.active)
|
|
draggable.enableForLayer(routeLayer);
|
|
else
|
|
draggable.disableForLayer(routeLayer);
|
|
});
|
|
|
|
watch(hashQuery, (hashQuery) => {
|
|
emit("hash-query-change", hashQuery);
|
|
});
|
|
|
|
watch(routeMode, () => {
|
|
void reroute(false);
|
|
});
|
|
|
|
function addDestination(): void {
|
|
destinations.value.push({
|
|
query: ""
|
|
});
|
|
}
|
|
|
|
function removeDestination(idx: number): void {
|
|
if (destinations.value.length > 2)
|
|
destinations.value.splice(idx, 1);
|
|
}
|
|
|
|
function getSelectedSuggestion(dest: Destination): Suggestion | undefined {
|
|
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;
|
|
}
|
|
|
|
function getSelectedSuggestionId(dest: Destination): string | undefined {
|
|
const sugg = getSelectedSuggestion(dest);
|
|
if (!sugg)
|
|
return undefined;
|
|
|
|
if (isMapResult(sugg))
|
|
return (sugg.kind == "marker" ? "m" : "l") + sugg.id;
|
|
else
|
|
return sugg.id;
|
|
}
|
|
|
|
function getSelectedSuggestionName(dest: Destination): string | undefined {
|
|
const sugg = getSelectedSuggestion(dest);
|
|
if (!sugg)
|
|
return undefined;
|
|
|
|
if (isMapResult(sugg))
|
|
return sugg.name;
|
|
else
|
|
return sugg.short_name;
|
|
}
|
|
|
|
async function loadSuggestions(dest: Destination): Promise<void> {
|
|
if (dest.loadingQuery == dest.query.trim()) {
|
|
await dest.loadingPromise;
|
|
return;
|
|
} else if (dest.loadedQuery == dest.query.trim())
|
|
return;
|
|
|
|
const idx = destinations.value.indexOf(dest);
|
|
toasts.hideToast(`fm${context.id}-route-form-suggestion-error-${idx}`);
|
|
dest.searchSuggestions = undefined;
|
|
dest.mapSuggestions = undefined;
|
|
dest.selectedSuggestion = undefined;
|
|
dest.loadingQuery = undefined;
|
|
dest.loadingPromise = undefined;
|
|
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([
|
|
client.value.find({ query: query }),
|
|
(async () => {
|
|
if (client.value.padData) {
|
|
const m = query.match(/^m(\d+)$/);
|
|
if (m) {
|
|
const marker = await client.value.getMarker({ id: Number(m[1]) });
|
|
return marker ? [{ kind: "marker" as const, similarity: 1, ...marker }] : [];
|
|
} else
|
|
return (await client.value.findOnMap({ query })).filter((res) => res.kind == "marker") as MapSuggestion[];
|
|
}
|
|
})()
|
|
])
|
|
|
|
if(query != dest.loadingQuery)
|
|
return; // The destination has changed in the meantime
|
|
|
|
dest.loadingQuery = undefined;
|
|
dest.loadedQuery = query;
|
|
dest.searchSuggestions = searchResults;
|
|
dest.mapSuggestions = mapResults;
|
|
|
|
if(isSearchId(query) && searchResults.length > 0 && searchResults[0].display_name) {
|
|
if (dest.query == query)
|
|
dest.query = searchResults[0].display_name;
|
|
dest.loadedQuery = searchResults[0].display_name;
|
|
dest.selectedSuggestion = searchResults[0];
|
|
}
|
|
|
|
if(mapResults) {
|
|
const referencedMapResult = mapResults.find((res) => query == `m${res.id}`);
|
|
if(referencedMapResult) {
|
|
if (dest.query == query)
|
|
dest.query = normalizeMarkerName(referencedMapResult.name);
|
|
dest.loadedQuery = referencedMapResult.name;
|
|
dest.selectedSuggestion = referencedMapResult;
|
|
}
|
|
}
|
|
|
|
if(dest.selectedSuggestion == null)
|
|
dest.selectedSuggestion = getSelectedSuggestion(dest);
|
|
} catch (err: any) {
|
|
if(query != dest.loadingQuery)
|
|
return; // The destination has changed in the meantime
|
|
|
|
console.warn(err.stack || err);
|
|
toasts.showErrorToast(`fm${context.id}-route-form-suggestion-error-${idx}`, () => i18n.t("route-form.find-destination-error", { query }), err);
|
|
} finally {
|
|
resolveLoadingPromise();
|
|
}
|
|
}
|
|
}
|
|
|
|
function suggestionMouseOver(suggestion: Suggestion): void {
|
|
suggestionMarker.value = markRaw((new MarkerLayer([ suggestion.lat!, suggestion.lon! ], {
|
|
highlight: true,
|
|
marker: {
|
|
colour: dragMarkerColour,
|
|
size: 35,
|
|
symbol: "",
|
|
shape: "drop"
|
|
}
|
|
})).addTo(mapContext.value.components.map));
|
|
}
|
|
|
|
function suggestionMouseOut(): void {
|
|
if(suggestionMarker.value) {
|
|
suggestionMarker.value.remove();
|
|
suggestionMarker.value = undefined;
|
|
}
|
|
}
|
|
|
|
function suggestionZoom(suggestion: Suggestion): void {
|
|
mapContext.value.components.map.flyTo([suggestion.lat!, suggestion.lon!]);
|
|
}
|
|
|
|
function destinationMouseOver(idx: number): void {
|
|
const marker = routeLayer._draggableLines?.dragMarkers[idx];
|
|
|
|
if (marker) {
|
|
hoverDestinationIdx.value = idx;
|
|
marker.setIcon(getIcon(idx, routeLayer._draggableLines!.dragMarkers.length, true));
|
|
}
|
|
}
|
|
|
|
function destinationMouseOut(idx: number): void {
|
|
hoverDestinationIdx.value = undefined;
|
|
|
|
const marker = routeLayer._draggableLines?.dragMarkers[idx];
|
|
if (marker) {
|
|
void Promise.resolve().then(() => {
|
|
// If mouseout event is directly followed by a dragend event, the marker will be removed. Only update the icon if the marker is not removed.
|
|
if (marker["_map"])
|
|
marker.setIcon(getIcon(idx, routeLayer._draggableLines!.dragMarkers.length));
|
|
});
|
|
}
|
|
}
|
|
|
|
function getValidationState(destination: Destination): boolean | null {
|
|
if (routeError.value && destination.query.trim() == '')
|
|
return false;
|
|
else if (destination.loadedQuery && destination.query == destination.loadedQuery && getSelectedSuggestion(destination) == null)
|
|
return false;
|
|
else
|
|
return null;
|
|
}
|
|
|
|
async function route(zoom: boolean, smooth = true): Promise<void> {
|
|
reset();
|
|
|
|
try {
|
|
const mode = routeMode.value;
|
|
|
|
submittedQuery.value = { destinations: cloneDeep(toRaw(destinations.value)), mode };
|
|
|
|
await Promise.all(destinations.value.map((dest) => loadSuggestions(dest)));
|
|
const points = destinations.value.map((dest) => getSelectedSuggestion(dest));
|
|
|
|
submittedQuery.value = { destinations: cloneDeep(toRaw(destinations.value)), mode };
|
|
|
|
if(points.some((point) => point == null)) {
|
|
routeError.value = i18n.t("route-form.some-destinations-not-found");
|
|
return;
|
|
}
|
|
|
|
const route = await client.value.setRoute({
|
|
routePoints: points.map((point) => ({ lat: point!.lat!, lon: point!.lon! })),
|
|
mode,
|
|
routeId: props.routeId
|
|
});
|
|
|
|
if (route && zoom)
|
|
flyTo(mapContext.value.components.map, getZoomDestinationForRoute(route), smooth);
|
|
} catch (err: any) {
|
|
toasts.showErrorToast(`fm${context.id}-route-form-error`, () => i18n.t("route-form.route-calculation-error"), err);
|
|
}
|
|
}
|
|
|
|
async function reroute(zoom: boolean, smooth = true): Promise<void> {
|
|
if(hasRoute.value) {
|
|
await Promise.all(destinations.value.map((dest) => loadSuggestions(dest)));
|
|
const points = destinations.value.map((dest) => getSelectedSuggestion(dest));
|
|
|
|
if(!points.some((point) => point == null))
|
|
await route(zoom, smooth);
|
|
}
|
|
}
|
|
|
|
function reset(): void {
|
|
toasts.hideToast(`fm${context.id}-route-form-error`);
|
|
submittedQuery.value = undefined;
|
|
routeError.value = undefined;
|
|
|
|
if(suggestionMarker.value) {
|
|
suggestionMarker.value.remove();
|
|
suggestionMarker.value = undefined;
|
|
}
|
|
|
|
client.value.clearRoute({ routeId: props.routeId });
|
|
}
|
|
|
|
function clear(): void {
|
|
reset();
|
|
|
|
destinations.value = [
|
|
{ query: "" },
|
|
{ query: "" }
|
|
];
|
|
}
|
|
|
|
function handleSubmit(event: Event): void {
|
|
submitButton.value?.focus();
|
|
void route(true);
|
|
}
|
|
|
|
const linesWithTags = computed((): LineWithTags[] | undefined => routeObj.value && [{
|
|
routePoints: routeObj.value.routePoints,
|
|
mode: routeObj.value.mode
|
|
}]);
|
|
|
|
async function getExport(format: ExportFormat): Promise<string> {
|
|
return await client.value.exportRoute({ format });
|
|
}
|
|
|
|
function setQuery(query: string, zoom = true, smooth = true): void {
|
|
clear();
|
|
const split = decodeRouteQuery(query);
|
|
destinations.value = split.queries.map((query) => ({ query }));
|
|
while (destinations.value.length < 2)
|
|
destinations.value.push({ query: "" });
|
|
routeMode.value = split.mode ?? "car";
|
|
void route(zoom, smooth);
|
|
}
|
|
|
|
function setFrom(data: Parameters<typeof makeDestination>[0]): void {
|
|
destinations.value[0] = makeDestination(data);
|
|
void reroute(true);
|
|
}
|
|
|
|
function addVia(data: Parameters<typeof makeDestination>[0]): void {
|
|
destinations.value.splice(destinations.value.length - 1, 0, makeDestination(data));
|
|
void reroute(true);
|
|
}
|
|
|
|
function setTo(data: Parameters<typeof makeDestination>[0]): void {
|
|
destinations.value[destinations.value.length - 1] = makeDestination(data);
|
|
void reroute(true);
|
|
}
|
|
|
|
defineExpose({ setQuery, setFrom, addVia, setTo });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="fm-route-form">
|
|
<form action="javascript:" @submit.prevent="handleSubmit">
|
|
<Draggable
|
|
v-model="destinations"
|
|
handle=".fm-drag-handle"
|
|
@end="reroute(true)"
|
|
:itemKey="(destination: any) => destinations.indexOf(destination)"
|
|
>
|
|
<template #item="{ element: destination, index: idx }">
|
|
<div class="destination" :class="{ active: hoverDestinationIdx == idx }">
|
|
<hr class="fm-route-form-hover-insert" :class="{ active: hoverInsertIdx === idx }"/>
|
|
<div
|
|
class="input-group"
|
|
@mouseenter="destinationMouseOver(idx)"
|
|
@mouseleave="destinationMouseOut(idx)"
|
|
>
|
|
<span class="input-group-text px-2">
|
|
<a href="javascript:" class="fm-drag-handle" @contextmenu.prevent>
|
|
<Icon icon="resize-vertical" :alt="i18n.t('route-form.reorder-alt')"></Icon>
|
|
</a>
|
|
</span>
|
|
<input
|
|
class="form-control"
|
|
v-model="destination.query"
|
|
:placeholder="idx == 0 ? i18n.t('route-form.from-placeholder') : idx == destinations.length-1 ? i18n.t('route-form.to-placeholder') : i18n.t('route-form.via-placeholder')"
|
|
:tabindex="idx+1"
|
|
:class="{
|
|
'is-invalid': destinationsMeta[idx].isInvalid,
|
|
'fm-autofocus': idx === 0
|
|
}"
|
|
@blur="loadSuggestions(destination)"
|
|
/>
|
|
<template v-if="destination.query.trim() != ''">
|
|
<DropdownMenu
|
|
menuClass="fm-route-form-suggestions"
|
|
noWrapper
|
|
@update:isOpen="$event && loadSuggestions(destination)"
|
|
:isLoading="!destination.searchSuggestions && !destination.mapSuggestions"
|
|
>
|
|
<template v-for="suggestion in destination.mapSuggestions" :key="suggestion.id">
|
|
<li
|
|
@mouseenter="suggestionMouseOver(suggestion)"
|
|
@mouseleave="suggestionMouseOut()"
|
|
>
|
|
<a
|
|
href="javascript:"
|
|
class="dropdown-item fm-route-form-suggestions-zoom"
|
|
:class="{ active: suggestion === getSelectedSuggestion(destination) }"
|
|
@click.capture.stop.prevent="suggestionZoom(suggestion)"
|
|
><Icon icon="zoom-in" :alt="i18n.t('route-form.zoom-alt')"></Icon></a>
|
|
|
|
<a
|
|
href="javascript:"
|
|
class="dropdown-item"
|
|
:class="{ active: suggestion === getSelectedSuggestion(destination) }"
|
|
@click="destination.selectedSuggestion = suggestion; reroute(true)"
|
|
>{{suggestion.name}} ({{formatTypeName(client.types[suggestion.typeId].name)}})</a>
|
|
</li>
|
|
</template>
|
|
|
|
<li v-if="(destination.searchSuggestions || []).length > 0 && (destination.mapSuggestions || []).length > 0">
|
|
<hr class="dropdown-divider fm-route-form-suggestions-divider">
|
|
</li>
|
|
|
|
<template v-for="suggestion in destination.searchSuggestions" :key="suggestion.id">
|
|
<li
|
|
@mouseenter="suggestionMouseOver(suggestion)"
|
|
@mouseleave="suggestionMouseOut()"
|
|
>
|
|
<a
|
|
href="javascript:"
|
|
class="dropdown-item fm-route-form-suggestions-zoom"
|
|
:class="{ active: suggestion === getSelectedSuggestion(destination) }"
|
|
@click.capture.stop.prevent="suggestionZoom(suggestion)"
|
|
><Icon icon="zoom-in" :alt="i18n.t('route-form.zoom-alt')"></Icon></a>
|
|
<a
|
|
href="javascript:"
|
|
class="dropdown-item"
|
|
:class="{ active: suggestion === getSelectedSuggestion(destination) }"
|
|
@click="destination.selectedSuggestion = suggestion; reroute(true)"
|
|
>{{suggestion.display_name}}<span v-if="suggestion.type"> ({{suggestion.type}})</span></a>
|
|
</li>
|
|
</template>
|
|
</DropdownMenu>
|
|
</template>
|
|
<button
|
|
v-if="destinations.length > 2"
|
|
type="button"
|
|
class="btn btn-secondary"
|
|
@click="removeDestination(idx); reroute(false)"
|
|
v-tooltip.right="i18n.t('route-form.remove-destination-tooltip')"
|
|
>
|
|
<Icon icon="minus" :alt="i18n.t('route-form.remove-destination-alt')" size="1.0em"></Icon>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<hr class="fm-route-form-hover-insert" :class="{ active: hoverInsertIdx === destinations.length }"/>
|
|
</template>
|
|
</draggable>
|
|
|
|
<div class="btn-toolbar">
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary"
|
|
@click="addDestination()"
|
|
v-tooltip.bottom="i18n.t('route-form.add-destination-tooltip')"
|
|
:tabindex="destinations.length+1"
|
|
>
|
|
<Icon icon="plus" :alt="i18n.t('route-form.add-destination-alt')"></Icon>
|
|
</button>
|
|
|
|
<RouteMode v-if="context.settings.routing" v-model="routeMode" :tabindex="destinations.length+2" tooltip-placement="bottom"></RouteMode>
|
|
|
|
<button
|
|
type="submit"
|
|
class="btn btn-primary flex-grow-1"
|
|
:tabindex="destinations.length+7"
|
|
ref="submitButton"
|
|
>{{i18n.t("route-form.submit")}}</button>
|
|
<button
|
|
v-if="hasRoute && !props.noClear"
|
|
type="button"
|
|
class="btn btn-secondary"
|
|
:tabindex="destinations.length+8"
|
|
@click="reset()"
|
|
v-tooltip.right="i18n.t('route-form.clear-route-tooltip')"
|
|
>
|
|
<Icon icon="remove" :alt="i18n.t('route-form.clear-route-alt')"></Icon>
|
|
</button>
|
|
</div>
|
|
|
|
<template v-if="routeError">
|
|
<hr />
|
|
|
|
<div class="alert alert-danger">{{routeError}}</div>
|
|
</template>
|
|
|
|
<template v-if="routeObj">
|
|
<hr />
|
|
|
|
<dl class="fm-search-box-dl">
|
|
<dt>{{i18n.t("route-form.distance")}}</dt>
|
|
<dd>{{formatDistance(routeObj.distance)}} <span v-if="routeObj.time != null">({{formatRouteTime(routeObj.time, routeObj.mode)}})</span></dd>
|
|
|
|
<template v-if="routeObj.ascent != null">
|
|
<dt>{{i18n.t("route-form.ascent-descent")}}</dt>
|
|
<dd><ElevationStats :route="routeObj"></ElevationStats></dd>
|
|
</template>
|
|
</dl>
|
|
|
|
<ElevationPlot :route="routeObj" v-if="routeObj.ascent != null"></ElevationPlot>
|
|
|
|
<div v-if="showToolbar && !client.readonly" class="btn-toolbar" role="group">
|
|
<ZoomToObjectButton
|
|
v-if="zoomDestination"
|
|
:label="i18n.t('route-form.zoom-to-object-label')"
|
|
size="sm"
|
|
:destination="zoomDestination"
|
|
></ZoomToObjectButton>
|
|
|
|
<AddToMapDropdown
|
|
:lines="linesWithTags"
|
|
size="sm"
|
|
isSingle
|
|
></AddToMapDropdown>
|
|
|
|
<ExportDropdown
|
|
:filename="i18n.t('route-form.export-filename')"
|
|
:getExport="getExport"
|
|
size="sm"
|
|
></ExportDropdown>
|
|
</div>
|
|
</template>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
.fm-route-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
flex-grow: 1;
|
|
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-grow: 1;
|
|
}
|
|
|
|
.destination.active .input-group {
|
|
box-shadow: 0 0 3px;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.destination:first-child {
|
|
margin-top: calc(-0.5rem + 2px); // Offset space of first fm-route-form-hover-insert
|
|
}
|
|
|
|
&#{&} hr.fm-route-form-hover-insert {
|
|
margin: 0.1rem -0.5rem;
|
|
width: auto;
|
|
border-width: 2px;
|
|
border-color: inherit;
|
|
border-top-style: dashed;
|
|
|
|
&:not(.active) {
|
|
border-color: transparent;
|
|
}
|
|
}
|
|
|
|
.fm-elevation-plot {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
}
|
|
|
|
.dropdown-menu.fm-route-form-suggestions.show {
|
|
opacity: 0.6;
|
|
|
|
> li {
|
|
display: flex;
|
|
|
|
> :nth-child(2) {
|
|
flex-grow: 1;
|
|
}
|
|
}
|
|
|
|
.dropdown-item {
|
|
width: auto;
|
|
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
|
|
|
|
&.fm-route-form-suggestions-zoom {
|
|
padding: 0.25rem 0.25rem 0.25rem 0.75rem;
|
|
}
|
|
}
|
|
}
|
|
</style> |