Zakladni verze planovace

This commit is contained in:
Michal Kváček
2024-04-15 18:13:22 +02:00
parent 338ec13db2
commit cee979fec8
15 changed files with 672 additions and 304 deletions
+3 -3
View File
@@ -34,6 +34,8 @@ import {useEventsStore} from "stores/Events";
import FlightEditCopilotSkeleton from "components/flights/skeletons/FlightEditCopilotSkeleton.vue";
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
import {usePhotosStore} from "stores/Photos";
import {flightName} from "src/composables/flight";
import {useAppStore} from "stores/App";
const $q = useQuasar();
const $route = useRoute();
@@ -47,12 +49,10 @@ const {events} = storeToRefs(useEventsStore());
const {photos} = storeToRefs(usePhotosStore());
const {flight, isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
const formStep = ref("basic");
watch(() => $route.params.step, (val) => formStep.value = val);
import {flightName} from "src/composables/flight";
import {useAppStore} from "stores/App";
const flightNameFormatted = flightName(flight.value);
useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`);
useAppStore().setBreadcrumbs([
@@ -1,76 +1,402 @@
<script>
import {useFlightPlansStore} from "stores/FlightPlans";
export default {
preFetch({currentRoute}) {
return useFlightPlansStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import {computed, onMounted} from "vue";
import {useAppStore} from "stores/App";
import CopilotEditSkeleton from "components/copilots/skeletons/CopilotEditSkeleton.vue";
import CopilotEditForm from "components/copilots/CopilotForm.vue";
import {useRoute, useRouter} from "vue-router";
import {useCopilotsStore} from "stores/Copilots";
import {successObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
import {computed, onMounted, ref, watch} from "vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {Marker, CustomMarker, Polyline, InfoWindow} from "vue3-google-map";
import {useAirspacesStore} from "stores/Airspaces";
import MapWrapper from "components/map/MapWrapper.vue";
import {useGeocode} from "src/composables/geocoding";
import {useAirportsStore} from "stores/Airports";
import {commonProps} from "src/composables/inputs";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {useAppStore} from "stores/App";
const $q = useQuasar();
const $router = useRouter();
const $route = useRoute();
const copilotId = parseInt($route.params.id);
const {flightPlan} = storeToRefs(useFlightPlansStore());
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
const {airspaces: airspacesData, areAirspacesLoading: isQueryRunning} = storeToRefs(useAirspacesStore())
const {airports} = storeToRefs(useAirportsStore());
onMounted(() => {
useCopilotsStore().fetchDetail(copilotId).then(() => {
useAppStore().setPageTitle(`${copilot.value.name} - editace`);
useAppStore().setBreadcrumbs([
{to: {name: "copilots"}, label: "Kopiloti"},
{to: {name: "copilotDetail", params: {id: copilotId}}, label: copilot.value.name},
{label: "Editace"}
])
});
useAirspacesStore().fetch();
usePointsOfInterestStore().fetch({limit: 10000});
useAirportsStore().fetch();
});
const handleSave = (formData) => {
useCopilotsStore().update(copilotId, formData).then(() => {
$q.notify(successObject("Kopilot úspěšně upraven"));
$router.push({name: "copilotDetail", params: {id: copilotId}});
});
const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]);
const {center, zoom, searchGeocode} = useGeocode(emits);
zoom.value = 8;
const placesOptions = ref(null);
const allPlacesOptions = computed(() => {
return [
...pointsOfInterest.value.map(poi => ({...poi, type: "poi"})),
...airports.value.map(airport => ({
...airport,
name: `${airport.icaoCode} - ${airport.name}`,
type: "airport",
}))
].map(i => ({...i, disabled: !i.gpsLatitude || !i.gpsLongitude}));
});
const placesFilter = (val, update, abort) => {
update(() => {
if (val && val !== '') {
placesOptions.value = allPlacesOptions.value.filter(p => p.name.includes(val));
if (placesOptions.value.length === 0) {
searchGeocode(val).then(results => {
if (!results) return;
placesOptions.value = results.map(result => ({
name: result.formatted_address,
type: "geocode",
id: null,
gpsLatitude: result.geometry.location.lat,
gpsLongitude: result.geometry.location.lng,
}))
});
}
} else {
placesOptions.value = allPlacesOptions.value;
}
})
}
const {isQueryRunning, isMutationRunning, copilot} = storeToRefs(useCopilotsStore());
const formStep = computed(() => useRoute().params.step || "info");
const setTitlePhoto = (titlePhoto) => {
useCopilotsStore().update(copilotId, {titlePhotoId: titlePhoto.id}).then(() => {
$q.notify(successObject("Úvodní fotka nastavena"));
})
const formData = ref({
flightPlanName: flightPlan.value?.name || "",
plannedSpeed: 150
})
const place = ref("");
const prompt = ref(false);
const markers = ref([]);
markers.value = flightPlan.value.track.map(m => m.from);
if (flightPlan.value.track.length > 1) {
markers.value.push(flightPlan.value.track[flightPlan.value.track.length - 1].to);
}
const track = computed(() => markers.value.map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
watch(() => place.value, (newVal) => {
addPoint(newVal);
place.value = null;
});
const markerObject = ({name, type, gpsLatitude, gpsLongitude}) => ({
name, type, gpsLatitude, gpsLongitude
});
const addPoint = async (point) => {
if (!point) return;
markers.value.push(markerObject({...point}));
}
const removeMarker = (idx) => {
markers.value.splice(idx, 1);
}
watch(() => markers.value, () => {
useFlightPlansStore().update(flightPlan.value.id, {
name: formData.value.flightPlanName,
isPublic: false,
markers: markers.value.map(m => markerObject({
name: m.name,
type: m.type,
gpsLatitude: m.gpsLatitude,
gpsLongitude: m.gpsLongitude,
}))
});
}, {deep: true});
const getIcon = (type) => {
const icons = {
"poi": "pin_drop",
"geocode": "travel_explore",
"airport": "local_airport"
};
return icons[type] || "place"
}
const swapMarkers = (idx1, idx2) => {
const tmp = markers.value[idx1];
markers.value[idx1] = markers.value[idx2];
markers.value[idx2] = tmp;
};
const getDuration = (distance, speed) => Math.round(distance / speed * 60)
const totalDistance = computed(() => {
if (!flightPlan.value.track.length) return null;
return Math.round(flightPlan.value.track.map(t => t.distance).reduce((a, b) => a + b));
});
const totalTime = computed(() => {
if (!flightPlan.value.track.length) return null;
return flightPlan.value.track.map(t => getDuration(t.distance, formData.value.plannedSpeed)).reduce((a, b) => a + b)
});
const infoWindowObject = ({active, position, marker, airspaces}) => {
if (!position) position = {};
if (!position.lat) position.lat = 0;
if (!position.lng) position.lng = 0;
if (marker) {
position.lat = marker.gpsLatitude;
position.lng = marker.gpsLongitude;
}
return {active, position, airspaces, marker};
};
const infoWindow = ref(infoWindowObject({}));
const handleMapClick = ({clickCoordinates, airspaces}) => {
infoWindow.value = infoWindowObject({active: true, airspaces, position: clickCoordinates})
}
const handleMarkerClick = (marker) => {
infoWindow.value = infoWindowObject({active: true, marker})
}
const handleMarkerMove = (idx, event) => {
markers.value[idx].gpsLatitude = event.lat();
markers.value[idx].gpsLongitude = event.lng();
}
const editableNameTypes = ["geocode", "mapPoint"];
const flightPlanName = computed(() => flightPlan.value.name || "Nepojmenovaný plán");
useAppStore().setPageTitle(`${flightPlanName.value} - plánovnání`);
useAppStore().setBreadcrumbs([
{to: {name: "flightPlans"}, label: "Moje plány"},
{label: flightPlanName.value},
{label: "Editace"}
]);
const googleMapsMarkerObject = (marker) => {
const anchorPoints = {
airport: "CENTER",
geocode: "BOTTOM_CENTER",
poi: "BOTTOM_CENTER",
mapPoint: "BOTTOM_CENTER",
}
return {
// label: marker.name,
anchorPoint: anchorPoints[marker.type],
draggable: editableNameTypes.includes(marker.type),
position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}
}
}
</script>
<template>
<CommonEditSwitcher :route="{name: 'copilotEdit', params: {id: copilot.id}}"/>
<div class="row">
<div class="col">
<q-input v-model="formData.flightPlanName" label="Jméno letu" v-bind="commonProps"/>
</div>
</div>
<template v-if="formStep === 'info'">
<CopilotEditSkeleton v-if="isQueryRunning || !copilot"/>
<CopilotEditForm
v-else
:copilot="copilot"
:is-request-pending="isMutationRunning"
@save="handleSave"
/>
</template>
<div class="row">
<div class="col" style="min-width: 1px">
<MapWrapper
style="width: 100%; height: 100%; min-height: 500px"
:center="center"
:zoom="zoom"
:airspaces-data="airspacesData"
@map-click="handleMapClick"
>
<template v-else-if="formStep === 'photo'">
<PhotoGallerySkeleton v-if="isQueryRunning || !copilot" />
<BasicInfoBanner v-else-if="!isQueryRunning && copilot.photos.length === 0">
U tohoto kopilota nemáš označenou žádnou fotku. Bez toho není možné zvolit úvodní fotografii.
</BasicInfoBanner>
<PhotosEditorList
v-else
:photos="copilot.photos"
:title-photo="copilot.titlePhoto"
:show-actions="false"
@set-title-photo="setTitlePhoto"
/>
</template>
<InfoWindow v-if="infoWindow.active" @close="infoWindow.active=false" :options="{position: infoWindow.position, title: 'titulek'}">
<div class="text-h5">{{ infoWindow.marker?.name || "Bod v mapě" }}</div>
<div v-if="infoWindow.marker">
<span class="text-h6">
<q-icon :name="getIcon(infoWindow.marker.type)"/>
{{ infoWindow.marker.name }}
</span>
</div>
<template v-if="infoWindow.airspaces?.length">
<span class="text-h6">Prostory</span>
<div v-for="airspace in infoWindow.airspaces" :key="airspace">
{{ airspace.name }}
</div>
</template>
<q-btn class="q-mt-lg" color="secondary" @click="addPoint(infoWindow.marker || {name: `Bod ${markers.length}`, type: 'mapPoint', gpsLatitude: infoWindow.position.lat, gpsLongitude: infoWindow.position.lng} )">Přidat bod do trasy</q-btn>
</InfoWindow>
<template v-for="(marker, i) in markers" :key="marker">
<Marker
v-if="editableNameTypes.includes(marker.type)"
:options="googleMapsMarkerObject(marker)"
@click="handleMarkerClick(marker)"
@dragend="handleMarkerMove(i, $event.latLng)"
/>
<CustomMarker
v-else
:options="googleMapsMarkerObject(marker)"
@click="handleMarkerClick(marker)"
>
<q-icon :name="getIcon(marker.type)" style="font-size: 30px;color: #810000"/>
</CustomMarker>
</template>
<Polyline :options="{path: track, geodesic: true, strokeColor: '#ff0202', strokeOpacity: 0.5, strokeWeight: 5}"/>
</MapWrapper>
</div>
<div class="col-12 col-md-5 col-lg-3">
<q-list bordered class="q-ml-md-md q-pt-md">
<!-- <q-item>-->
<!-- <q-item-section>-->
<!-- <q-separator/>-->
<!-- </q-item-section>-->
<!-- </q-item>-->
<template v-for="(segment, i) in flightPlan.track" :key="segment">
<q-item dense>
<q-item-section>
<q-item-label class="text-h6">
<q-icon :name="getIcon(segment.from.type)"/>
{{ segment.from.name }}
</q-item-label>
<q-item-section class="q-py-lg text-grey-8">
{{ segment.bearing }}°,
{{ segment.distance }} km,
{{ minutesToHumanReadable(getDuration(segment.distance, formData.plannedSpeed)) }}
</q-item-section>
<q-item-label class="text-h6" v-if="i === flightPlan.track.length - 1">
<q-icon :name="getIcon(segment.to.type)"/>
{{ segment.to.name }}
</q-item-label>
</q-item-section>
</q-item>
</template>
<template v-if="totalDistance">
<q-item>
<q-item-section>
<q-separator/>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
Celková vzdálenost: {{ totalDistance }} km
<br>
Plánovaná doba letu: {{ minutesToHumanReadable(totalTime) }}
</q-item-section>
</q-item>
</template>
<q-item>
<q-item-section>
<q-btn @click="prompt=true" color="secondary">
{{ totalDistance ? 'Upravit trasu' : 'Sestavit trasu' }}
</q-btn>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-input v-model="formData.plannedSpeed" label="Plánovaná rychlost" v-bind="commonProps">
<template #append>km/h</template>
</q-input>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
<q-dialog full-width v-model="prompt">
<q-card>
<q-card-section>
<div class="text-h6">Přidat otočný bod</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-scroll-area style="height: 60vh;">
<q-list bordered separator class="full-width">
<q-item v-for="(marker, i) in markers" :key="marker">
<q-item-section side>
<q-icon :name="getIcon(marker.type)"></q-icon>
</q-item-section>
<q-item-section side>
<div>
<q-btn text-color="primary" v-if="i > 0" size="16px" @click="swapMarkers(i, i-1)" flat dense round icon="arrow_upward"/>
<q-btn text-color="primary" v-if="i < markers.length - 1" size="16px" @click="swapMarkers(i, i+1)" flat dense round icon="arrow_downward"/>
</div>
</q-item-section>
<q-item-section>
<div class="cursor-pointer" v-if="editableNameTypes.includes(marker.type)">
{{ marker.name }}
<q-icon name="edit"/>
<q-popup-edit v-model="marker.name" v-slot="scope">
<q-input v-model="scope.value" autofocus @keyup.enter="scope.set">
<template #append>
<q-icon name="edit"/>
</template>
</q-input>
</q-popup-edit>
</div>
<template v-else>
{{ marker.name }}
</template>
</q-item-section>
<q-item-section side class="text-right">
<q-btn size="12px" @click="removeMarker(i)" flat dense round color="red" icon="delete"/>
</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
<q-select
v-bind="commonProps"
v-model="place"
label="Místo"
use-input
input-debounce="100"
map-options
emit-value
option-label="name"
option-disable="disabled"
:options="placesOptions"
@filter="placesFilter"
>
<template #no-option>
<q-item>
<q-item-section>
Pis dal, najdeme neco na GoogleMaps!
</q-item-section>
</q-item>
</template>
</q-select>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Zavřít" v-close-popup/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
@@ -1,202 +1,21 @@
<script setup>
import {computed, defineEmits, onMounted, ref, watch} from "vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia";
import {useQuasar} from "quasar";
import {commonProps} from "src/composables/inputs";
import {Marker, Polyline} from "vue3-google-map";
import {useAirspacesStore} from "stores/Airspaces";
import MapWrapper from "components/map/MapWrapper.vue";
import {useGeocode} from "src/composables/geocoding";
import {errorObject} from "src/composables/dialog";
import {useAirportsStore} from "stores/Airports";
import {onMounted} from "vue";
import {useFlightPlansStore} from "stores/FlightPlans";
import {useRouter} from "vue-router";
const $router = useRouter();
const place = ref("");
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
const {airspaces: airspacesData, areAirspacesLoading: isQueryRunning} = storeToRefs(useAirspacesStore())
const $q = useQuasar();
onMounted(() => {
useAirspacesStore().fetch();
usePointsOfInterestStore().fetch({limit: 10000});
useAirportsStore().fetch()
});
const markers = ref([]);
watch(() => markers.value, (newVal) => {
}, {deep: true});
const addPoint = async (point) => {
if (!point) return;
markers.value.push(point);
// {
// label: point.name,
// type: point.type,
//
// position: {
// lat: point.gpsLatitude,
// lng: point.gpsLongitude
// }
// })
}
const track = computed(() => {
return markers.value.map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude}));
})
const removeMarker = (idx) => {
markers.value.splice(idx, 1);
}
const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]);
const {center, zoom, searchGeocode} = useGeocode(emits);
zoom.value = 8;
const {airports} = storeToRefs(useAirportsStore());
const placesOptions = ref(null);
const allPlacesOptions = computed(() => {
return [
...pointsOfInterest.value.map(poi => ({...poi, type: "poi"})),
...airports.value.map(airport => ({
...airport,
name: `${airport.icaoCode} - ${airport.name}`,
type: "airport",
}))
].map(i => ({...i, disabled: !i.gpsLatitude || !i.gpsLongitude}));
});
const placesFilter = (val, update, abort) => {
update(() => {
console.log(val);
if (val && val !== '') {
placesOptions.value = allPlacesOptions.value.filter(p => p.name.includes(val));
console.log(placesOptions.value);
if (placesOptions.value.length === 0) {
searchGeocode(val).then(results => {
placesOptions.value = results.map(result => ({
name: result.formatted_address,
type: "geocode",
id: null,
gpsLatitude: result.geometry.location.lat,
gpsLongitude: result.geometry.location.lng,
}))
});
}
} else {
placesOptions.value = [];
}
})
}
watch(() => place.value, (newVal) => {
addPoint(newVal);
place.value = null;
});
const saveMarkers = () => {
useFlightPlansStore().create({
onMounted(async () => {
await useFlightPlansStore().create({
name: "",
isPublic: false,
markers: markers.value.map(m => ({
name: m.name,
type: m.type,
gpsLatitude: m.gpsLatitude,
gpsLongitude: m.gpsLongitude,
}))
});
}
markers: []
}).then(fp => {
return $router.replace({name: "editFlightPlan", params: {id: fp.id}});
})
})
</script>
<template>
<div class="row">
<div class="col" style="min-width: 1px">
<MapWrapper style="width: 100%; height: 500px" :center="center" :zoom="zoom" :airspaces-data="airspacesData">
<template v-for="marker in markers" :key="marker">
<Marker :options="{label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}}"/>
</template>
<Polyline :options="{path: track, geodesic: true, strokeColor: '#ff0000', strokeOpacity: 0.5, strokeWeight: 5}"/>
</MapWrapper>
</div>
<div class="col-12 col-md-5 col-lg-3" v-if="markers.length">
<q-list bordered class="q-ml-md-md">
<template v-for="(marker, i) in markers" :key="marker">
<q-item>
<q-item-section>
<q-item-label>
{{ i + 1 }}. {{ marker.label }}
</q-item-label>
<q-item-section caption>
{{ marker.type }}
</q-item-section>
<q-item-label caption>
<q-btn icon="add" color="green" flat dense round></q-btn>
</q-item-label>
</q-item-section>
<q-item-section middle side>
<div class="text-grey-8 q-gutter-xs">
<!-- <q-btn class="gt-xs" size="12px" flat dense round icon="delete" />-->
<!-- <q-btn class="gt-xs" size="12px" flat dense round icon="done" />-->
<!-- <q-btn size="12px" flat dense round icon="more_vert" />-->
<q-btn color="red" flat round dense icon="delete" @click="removeMarker(i)"/>
</div>
</q-item-section>
</q-item>
</template>
</q-list>
</div>
</div>
<q-select
v-model="place"
label="Místo"
use-input
input-debounce="100"
map-options
emit-value
option-label="name"
option-disable="disabled"
:options="placesOptions"
@filter="placesFilter"
>
<!-- <template #option="{opt}">-->
<!-- <q-item>-->
<!-- <q-item-section>-->
<!-- Option! {{ opt }}-->
<!-- </q-item-section>-->
<!-- </q-item>-->
<!-- </template>-->
</q-select>
<q-btn @click="saveMarkers" color="primary">Save</q-btn>
<!-- <q-input type="text" v-bind="commonProps" v-model="place" label="Misto"/>-->
<q-list bordered v-if="false">
<q-item v-for="poi in pointsOfInterest" :key="poi">
<q-item-section>{{ poi.name }}</q-item-section>
<q-item-section v-if="poi.gpsLatitude">
<q-btn @click="addPoint(poi)">Přidat do trasy</q-btn>
</q-item-section>
</q-item>
<q-item v-if="pointsOfInterest.length === 0 && place !== ''">
<q-btn @click="addPoint(null, place)">Najít {{ place }} na mapě</q-btn>
</q-item>
</q-list>
<!-- <q-btn @click="searchPosition">Najit</q-btn>-->
<!-- <MapWithMarkers :markers="markers" />-->
<q-spinner/>
</template>