<template>
<!-- <q-card dense class="dialog q-pa-none q-ma-none max-width" flat> -->
<div class="max-width">
<q-tabs dense v-model="tabToShow" align="justify" class="q-pa-none q-ma-none max-width" narrow-indicator
@before-transition="tabChange" indicator-color="primary" active-color="primary">
<q-tab name="general" :label="$t('General')" no-caps />
<q-tab v-if="custom_geometry_id != null" name="times" :label="$t('Properties')" no-caps />
<q-tab v-if="custom_geometry_id != null" name="files" :label="$t('Images')" no-caps />
</q-tabs>
<q-tab-panels dense v-model="tabToShow" animated>
<q-tab-panel dense name="general">
<q-form greedy @submit="saveGeneral" @validationError="valid = false" @validationSuccess="valid = true">
<autocomplete class="q-ml-xs" :options="$store.geometryProps" v-model="propsEdit[0].value"
emit-value map-options style="width:300px;" :clearable="false" :label="$t('Type')" />
<div class="row">
<q-input class="q-ml-md q-pb-sm" dense v-model="propsEdit[1].value" :label="$t('Name') + ' *'"
style="width:250px;" :rules="[val => val && val.length > 0 || $t('Required')]" />
</div>
<q-checkbox dense class="q-ml-md" v-model="propsEdit[2].value" :label="$t('Public')" />
<q-card-actions align="right">
<q-btn v-if="hasFeatureStatsButton" flat dense @click="showFeatureStats">{{ $t('Statistics')
}}</q-btn>
<q-btn v-if="custom_geometry_id != null && !isChanged" flat color="negative"
@click="deleteGeneral" :label="$t('Delete')"></q-btn>
<q-btn flat color="primary" @click="cancelGeneral"
:label="isChanged || custom_geometry_id == null ? $t('Cancel') : $t('Close')"></q-btn>
<q-btn type="submit" v-if="isChanged" flat color="positive" :label="$t('Save')"></q-btn>
</q-card-actions>
</q-form>
</q-tab-panel>
<q-tab-panel dense name="times">
<q-form greedy @submit="saveTime" @validationError="valid = false" @validationSuccess="valid = true">
<div class="row" style="align-items: center; justify-content: space-between; ">
<q-input class="q-ml-md" type="date" v-model="propsEdit[0].value" :label="$t('Date')"
style="width:200px;" />
</div>
<div class="row q-pb-sm" v-for="p of propsEdit.filter(x => x.property_id)" :key="p.property_id"
style="display: flex; align-items: flex-end;">
<autocomplete v-if="p.indicator_id && !p.numerical" class="q-ml-xs"
:options="$store.catalogs.descriptions.filter(x => x.indicator_id == p.indicator_id)"
:label="p.name + req(p)" emit-value map-options v-model="p.value" style="width:300px;" />
<q-checkbox v-else-if="p.data_type_id == 41" dense class="q-ml-md" v-model="p.value"
:label="p.name + req(p)" />
<q-input class="q-ml-md" v-else dense v-model="p.value" :label="p.name" style="width:250px;"
:type="buildType(p)" :rules="buildRules(p)" hide-bottom-space>
<template v-slot:label>
<label for="my-input" v-html="p.name + req(p)"></label>
</template>
</q-input>
<span style="color:gray; padding-bottom: 5px;" v-html="p.unit" />
</div>
<q-card-actions align="between">
<span>
<q-btn round flat :color="buttonColor(indexTime <= 0)" icon="chevron_left" @click="back" />
<span v-if="feature.times[indexTime].id">
{{ (indexTime + 1) + "/" + feature.times.length }}
</span>
<span v-else> {{ $t('New') }} </span>
<q-btn round flat :color="buttonColor(indexTime >= feature.times.length - 1)"
icon="chevron_right" @click="forward" />
<q-btn round flat icon="add" :color="buttonColor(!feature.times[indexTime].id)"
@click="add" /></span> <span>
<q-btn v-if="feature.times.length > 0 && feature.times[indexTime].id && !isChanged" flat
color="negative" @click="deleteTime" :label="$t('Delete')"></q-btn>
<q-btn flat color="primary" @click="cancelTime"
:label="isChanged || custom_geometry_id == null ? $t('Cancel') : $t('Close')"></q-btn>
<q-btn type="submit" v-if="isChanged" flat color="positive" :label="$t('Save')"></q-btn>
</span>
</q-card-actions>
</q-form>
</q-tab-panel>
<q-tab-panel name="files">
<div v-if="files.length == 0">{{ $t("No images") }}</div>
<q-markup-table class="q-pa-none q-ma-none" dense flat v-if="files.length > 0">
<template v-for="(f, index) of files" :key="f.id">
<tr>
<td><q-btn class="q-pa-none" dense flat icon="edit" @click="editImage(index)">
<q-tooltip>{{ $t("Edit properties") }}</q-tooltip>
</q-btn></td>
<td><q-btn class="q-pa-none" dense flat icon="visibility" @click="showImage(index)">
<q-tooltip>{{ $t("Show image") }}</q-tooltip>
</q-btn></td>
<td><q-btn class="q-pa-none" dense flat color="negative" @click="deleteFile(f)"
icon="delete">
<q-tooltip>{{ $t("Delete image") }}</q-tooltip>
</q-btn></td>
<td @click="showImage(index)" class="q-pa-none"
style="max-width:300px; overflow: hidden; cursor: pointer;">
{{ f.name }}</td>
</tr>
<tr v-if="editIndex == index">
<td colspan="4">
<q-input v-model="f.name" dense :label="$t('Name')" style="width:300px;" />
<q-input v-model="f.compass" dense :label="$t('Compass')" style="width:300px;" />
<div class="row">
<q-input v-model="f.lon" dense :label="$t('Longitude')" style="width:300px;" />
<q-btn no-caps flat :icon="$icons.my_location" @click="setGPSPosition"
:label="$t('Set GPS position')"></q-btn>
</div>
<div class="row">
<q-input v-model="f.lat" dense :label="$t('Latitude')" style="width:300px;" />
<q-btn no-caps flat icon="location_on" @click="setLocationPosition"
:label="$t('Set location position')"></q-btn>
</div>
<q-btn flat color="positive" @click="saveImage" :label="$t('Save')"></q-btn>
<q-btn flat color="negative" @click="cancelImage" :label="$t('Cancel')"></q-btn>
</td>
</tr>
</template>
</q-markup-table>
<q-uploader style="width:100%" ref="uploader" class="q-pt-md" flat bordered color="primary"
:label="$t('Upload')" multiple accept=".jpg, .jpeg, .png" @failed="filesFailed"
@uploaded="filesUploaded" @added="filesAdded" :url="uploadURL" :form-fields="formFields"
:headers="[{ name: 'Authorization', value: 'Bearer ' + this.$keycloak.token }]" />
<div><span class="q-ml-sm">{{ $t("Set position of uploaded image to") }}</span>
<q-radio v-model="coordType" val="gps" :label="$t('GPS')" />
<q-radio v-model="coordType" val="loc" :label="$t('Location')" />
</div>
</q-tab-panel>
</q-tab-panels>
<ImageViewer ref="imageViewer" />
</div>
</template>
<script>
/**
* Form for editing properties of a feature.
*
* @component
* @name OlMapProps
* @example
* <OlMapProps />
*/
import Autocomplete from "./autocomplete.vue";
import FileUploader from "./file-uploader.vue";
import ImageViewer from "./image-viewer.vue";
import { MapEditingMixin } from "../mixins/ol-map-editing";
import dayjs from "dayjs";
import { toLonLat } from "ol/proj";
export default {
name: "OlMapProps",
mixins: [MapEditingMixin],
components: {
Autocomplete,
FileUploader,
ImageViewer
},
props: {
caption: null,
loadFromServer: false,
},
data: () => ({
options: {},
feature: null,
custom_geometry_id: null,
props: [],
propsEdit: [],
existingTime: false,
parent: null,
valid: false,
files: [],
newFiles: [],
file: null,
tabToShow: null,
loaded: false,
indexTime: 0,
editIndex: -1,
fileSave: {},
coordType: "gps"
}),
computed: {
/**
* Checks should we display feature stats button
*
* @returns {boolean} True if the feature has a history button, false otherwise.
*/
hasFeatureStatsButton() {
return this.parent && this.parent.selectedTitle && this.parent.selectedFeature.get("custom_geometry_id") && this.parent.selectedFeature.getGeometry().getType() != "Point";
},
/**
* Checks if any property has been changed.
* @returns {boolean} True if any property has been changed, false otherwise.
*/
isChanged: function () {
return this.propsEdit.length > 0 && !this.equalArrays(this.props, this.propsEdit);
},
/**
* Returns the upload URL for file picker.
*
* @returns {string} The upload URL.
*/
uploadURL: function () {
return this.axios.API.defaults.baseURL + 'User/UploadFiles/' + this.custom_geometry_id;
}
},
watch: {
/**
* Watches for changes in the tabToShow
*
* @param {any} val - The value to set the tab to show.
*/
tabToShow: function (val) {
if (val == "general") {
this.initGeneral();
} else if (val == "times") {
this.showTime(Math.max(0, this.feature.times.length - 1));
}
},
/**
* Watches for changes in the `isChanged`.
*
* @param {bool} val - The new value of the `isChanged`
*/
isChanged: function (val) {
this.$store.formChanged = this.isChanged;
}
},
/**
* Initializes the component
*/
async mounted() {
this.loaded = false;
this.parent = this.$store.popup.props.parent;
// load catalog if not loaded
if (this.$store.geometryProps.length == 0) {
this.$store.geometryProps = await this.get("Home/GetGeometryTypeProperties", null, true);
}
if (this.$store.popup.props.custom_geometry_id != null) { // if called from the map
this.custom_geometry_id = this.$store.popup.props.custom_geometry_id;
} else if (this.$store.popup.props.row) { // if called from the table
this.custom_geometry_id = this.$store.popup.props.row.id;
}
if (this.custom_geometry_id) { // if editing existing feature, load it
this.feature = await this.get("User/GetCustomGeometryProps", {
id: this.custom_geometry_id
}, true);
} else { // create new feature
this.feature = {
general: {
geometry_type_id: (this.$store.popup.props.geometry_type == "Point" ? 1 : 2),
name: "",
public: false
},
times: [],
files: []
};
}
this.geometry_type_id = this.feature.general.geometry_type_id;
this.files = this.feature.files ?? [];
window.addEventListener('deviceorientationabsolute', this.getCompass);
this.tabToShow = this.$store.popup.props.tabToShow ?? "general";
this.loaded = true;
},
/**
* Removes event listener for deleteorientationabsolute.
*/
unmounted() {
window.removeEventListener('deviceorientationabsolute', this.getCompass);
},
methods: {
showFeatureStats() {
this.parent.showFeatureStats();
},
/**
* Checks if the tab has changed.
*
* @param {string} oldValue - The old value of the tab.
* @param {string} newValue - The new value of the tab.
* @returns {boolean} True if the tab has changed, false otherwise.
*/
tabChange(oldValue, newValue) {
if (this.isChanged()) return false;
return true;
},
/**
*
* Closes properties window.
*/
closePopup() {
this.$store.popup.show = false;
if (this.parent && this.parent.map) this.parent.map.updateSize();
},
/**
* Adds a new item.
*/
add() {
if (this.feature.times[this.indexTime].id) {
this.showTime(this.indexTime + 1);
}
},
/**
* Shows the previous time.
*/
back() {
if (this.indexTime > 0) {
this.showTime(this.indexTime - 1);
}
},
/**
* Shows the next time.
*/
forward() {
if (this.indexTime < this.feature.times.length - 1) {
this.showTime(this.indexTime + 1);
}
},
/**
* Retrieves the compass value
*
* @param {Event} event - The event object.
* @returns {Compass} The compass value.
*/
getCompass(event) {
if (event.alpha == null) this.$store.compass = null;
else this.$store.compass = Math.abs(event.alpha - 360);
},
/**
* Returns the color of the left/right (or add) button.
*
* @returns {string} The color of the button.
*/
buttonColor(disabled) {
return (this.isChanged || disabled) ? "red" : "green";
},
/**
* Returns URLs of the files for download from server.
*
* @returns {array} URLs of the files.
*/
filesUrls() {
console.log(this.files);
return this.files.map(x => ({ url: this.axios.API.defaults.baseURL + "User/GetFile/" + this.custom_geometry_id + "/" + x.id + "/" + x.extension, compass: x.compass, file_id: x.id }));
},
/**
* Builds the input type for the given property.
*
* @param {any} p - The parameter to build the type for.
*/
buildType(p) {
return p.numerical ? "number" : "text";
},
/**
* Builds validation rules based on the provided parameter.
*
* @param {any} p - The property on wich the rules are built
*/
buildRules(p) {
let rules = [];
if (p.required) {
rules.push(val => val && val.length > 0 || this.$t("Required"));
}
if (p.value_from) {
rules.push(val => val >= p.value_from && val <= p.value_to || this.$t("Allowed interval:") + " " + p.value_from + " - " + p.value_to);
}
return rules;
},
/**
* Appends asterisk to label if the field is required.
*
* @param {any} p - The field passed to the `req` function.
*/
req(p) {
return p.required ? " *" : "";
},
/**
* Initializes the general properties of the feature.
*/
initGeneral() {
this.propsEdit = [
{ name: "geometry_type_id", value: this.feature.general.geometry_type_id },
{ name: "name", value: this.feature.general.name },
{ name: "public", value: this.feature.general.public }];
this.copyArray(this.propsEdit, this.props);
},
/**
* Cancels edits or closes popup.
*/
cancelGeneral() {
if (this.custom_geometry_id == null) {
if (this.parent && this.parent.editableSource) {
this.parent.editableSource.removeFeature(this.parent.selectedFeature);
this.parent.selectedFeature = null;
}
this.$store.formChanged = false;
this.closePopup();
} else {
if (!this.isChanged) {
this.closePopup();
} else {
this.initGeneral();
}
}
},
/**
* Deletes entire feature.
*
* @async
*/
async deleteGeneral() {
if (await this.confirmDialog(this.$t("Delete location?"))) {
await this.delete("User/DeleteCustomGeometry/" + this.custom_geometry_id);
if (this.parent && this.parent.editableSource) {
for (let file of this.files) {
let f = this.parent.editableSource.getFeatureById('f' + file.id);
if (f) this.parent.editableSource.removeFeature(f);
}
this.parent.editableSource.removeFeature(this.parent.selectedFeature);
this.parent.selectedFeature = null;
}
this.$store.formChanged = false;
this.closePopup();
}
},
/**
* Save changes in general properties of a feature.
*/
async saveGeneral() {
this.feature.general.geometry_type_id = this.propsEdit[0].value;
this.feature.general.name = this.propsEdit[1].value;
this.feature.general.public = this.propsEdit[2].value;
let ret = await this.post("User/SetCustomGeometry", {
id: this.custom_geometry_id ? this.custom_geometry_id.toString() : null,
geometry_type_id: this.propsEdit[0].value.toString(),
name: this.propsEdit[1].value,
public: this.propsEdit[2].value.toString()
});
if (ret) {
if (this.custom_geometry_id == null) {
this.parent.selectedFeature.set("custom_geometry_id", ret);
this.parent.saveFeature(this.parent.selectedFeature, this.parent.format, this.parent.projection, this.parent.projectionForSave);
this.custom_geometry_id = ret;
}
this.copyArray(this.propsEdit, this.props);
if (this.parent && this.parent.selectedFeature) {
this.parent.selectedFeature.set("name", this.propsEdit[1].value);
this.parent.infoTip.innerText = this.propsEdit[1].value;
}
if (this.parent && this.parent.updateRow) {
let label = this.$store.geometryProps.find(x => x.value == this.propsEdit[0].value).label;
this.parent.updateRow({
name: this.propsEdit[1].value,
public: this.propsEdit[2].value,
type: label
});
}
}
},
/**
* show properties for the selected time
*/
showTime(index) {
// prevent changing time if changes not saved
if (index != this.indexTime && this.isChanged) return;
this.indexTime = index;
if (this.indexTime >= this.feature.times.length) { // add new time
this.feature.times.push({ id: null, observation_time: dayjs(new Date()).format('YYYY-MM-DD'), props: [], files: [] });
}
let selectedType = this.$store.geometryProps.find(x => x.value == this.feature.general.geometry_type_id);
this.copyArray(selectedType.properties.sort((a, b) => a.order_no - b.order_no), this.propsEdit);
for (let p of this.propsEdit) {
let v = this.feature.times[this.indexTime].props.find(x => x.property_id == p.property_id);
if (!v || v.value == null) {
p.value = null;
} else {
p.value = p.indicator_id && !p.numerical ? parseInt(v.value) : v.value;
}
}
this.propsEdit.unshift({ name: "Date", value: dayjs(this.feature.times[this.indexTime].observation_time).format('YYYY-MM-DD') });
this.copyArray(this.propsEdit, this.props);
// set area if not set
let area = this.propsEdit.find(x => x.property_id == 1);
if (area && this.parent && this.parent.selectedFeature && (area.value == null || area.value == "")) {
area.value = this.parent.getFeatureArea(this.parent.selectedFeature);
this.props.find(x => x.property_id == 1).value = area.value;
}
},
/**
* Saves properties for the selected time.
*
* @returns {Promise<void>} A promise that resolves when the time is saved.
*/
async saveTime() {
let time = this.feature.times[this.indexTime];
time.observation_time = this.propsEdit[0].value;
time.props = this.propsEdit.filter(x => x.property_id).map(x => {
return {
property_id: x.property_id,
value: x.value
}
});
let ret = await this.post("User/SetCustomGeometryTime", {
id: time.id ? time.id.toString() : null,
custom_geometry_id: this.custom_geometry_id.toString(),
observation_time: time.observation_time.toString(),
props: JSON.stringify(time.props)
});
if (ret) {
time.id = ret;
this.copyArray(this.propsEdit, this.props);
}
},
/**
* Deletes properties for the selected time.
*
* @async
* @returns {Promise<void>} A promise that resolves when the time is deleted.
*/
async deleteTime() {
if (await this.confirmDialog(this.$t("Delete this observation?"))) {
if (await this.delete("User/DeleteCustomGeometryTime/" + this.feature.times[this.indexTime].id) != null) {
this.feature.times.splice(this.indexTime, 1);
this.showTime(Math.max(this.indexTime - 1, 0));
}
}
},
/**
* Cancels changes for the selected time or closes the popup.
*/
cancelTime() {
if (this.isChanged) {
this.showTime(this.indexTime);
} else {
this.closePopup();
}
},
/**
* Handles the event when files fail to load.
*
* @param {Object} info - Information about the failed files.
*/
filesFailed(info) {
this.showError(this.$t("File upload failed") + "<br>" + JSON.stringify(info));
},
/**
* Callback function that is called when files are uploaded.
*
* @param {any} response - The response object returned after the files are uploaded.
*/
filesUploaded(response) {
let newFiles = JSON.parse(response.xhr.response);
this.files = [...this.files, ...newFiles];
if (this.parent && this.parent.editableSource) {
for (let file of newFiles) {
this.parent.addFileFeature(file);
}
}
},
/**
* Gets the GPS position.
*/
async getGPSPosition() {
let coords = { latitude: null, longitude: null };
if (this.parent && this.parent.trackPosition) {
let c = toLonLat(this.parent.geolocation.getPosition(), this.parent.projection);
coords = { latitude: c[1], longitude: c[0] };
} else {
let position = await this.getCurrentPosition();
coords = position.coords;
}
return coords;
},
/**
* Gets the position of selected feature.
*/
getLocationPosition() {
let coords = { latitude: null, longitude: null }, f, c;
if (this.parent && this.parent.selectedFeature) {
if (this.parent.selectedFeature.getGeometry().getType() == "Polygon") {
f = this.parent.selectedFeature.getGeometry().getInteriorPoint().getCoordinates();
} else {
f = this.parent.selectedFeature.getGeometry().getCoordinates();
}
c = toLonLat(f, this.parent.projection);
} else {
c = [this.feature.general.lon, this.feature.general.lat];
}
coords = { latitude: c[1], longitude: c[0] };
return coords;
},
/**
* Sets the coordinates of a file to GPS position.
*/
async setGPSPosition() {
let coords = await this.getGPSPosition();
this.files[this.editIndex].lat = coords.latitude;
this.files[this.editIndex].lon = coords.longitude;
},
/**
* Sets the coordinates of a file to feature position.
*/
setLocationPosition() {
let coords = this.getLocationPosition();
this.files[this.editIndex].lat = coords.latitude;
this.files[this.editIndex].lon = coords.longitude;
},
/**
* Callback function that is called when files are added to the list. Adds compass and position values to files.
*
* @param {any} files - List of files added.
*/
async filesAdded(files) {
let coords = { latitude: null, longitude: null };
if (this.coordType == "gps") {
coords = await this.getGPSPosition();
}
if (coords.latitude == null || coords.longitude == null) {
// get location of the feature, or inner point of the polygon
if (this.parent && this.parent.selectedFeature) {
coords = this.getLocationPosition();
}
}
for (let file of files) {
file.lat = coords.latitude;
file.lon = coords.longitude;
file.compass = this.$store.compass;
}
this.$refs.uploader.upload();
},
/**
* Deletes a single attached file asynchronously.
*
* @param {Object} file - The file to be deleted.
* @returns {Promise} A promise that resolves when the file is successfully deleted.
*/
async deleteFile(file) {
if (await this.confirmDialog(this.$t("Delete image?"))) {
if (await this.delete("User/DeleteFile/" + this.custom_geometry_id + "/" + file.id) != null) {
this.files = this.files.filter(x => x.id != file.id);
if (this.parent && this.parent.editableSource) {
let f = this.parent.editableSource.getFeatureById('f' + file.id);
if (f) this.parent.editableSource.removeFeature(f);
}
}
}
},
/**
* Opens ImageViewer at a given index.
*
* @param {number} index - The index of the image to show.
*/
showImage(index) {
console.log(this.filesUrls());
this.$refs.imageViewer.show(this.filesUrls(), index, true);
},
/**
* Creates array of additional atrributes to be uploaded with files
*
* @returns {Array} The form fields.
*/
formFields(files) {
return files.map(x => {
return {
name: x.name,
value: JSON.stringify({ compass: x.compass, lat: x.lat, lon: x.lon })
}
});
},
/**
* Starts editing the properties of an image.
*
* @param {number} index - The index of the image to edit.
*/
editImage(index) {
this.editIndex = index;
this.copyObject(this.files[index], this.fileSave);
},
/**
* Cancels editing the properties of an image.
*/
cancelImage() {
this.copyObject(this.fileSave, this.files[this.editIndex]);
this.editIndex = -1;
},
/**
* Save modified properties of an image.
*/
async saveImage() {
await this.put("User/UpdateFile", this.files[this.editIndex]);
if (this.parent && this.parent.editableSource) {
let f = this.parent.editableSource.getFeatureById('f' + this.files[this.editIndex].id);
if (f) {
this.parent.editableSource.removeFeature(f);
this.parent.addFileFeature(this.files[this.editIndex]);
}
}
this.editIndex = -1;
},
},
}
</script>
<style scoped>
.dialog {
background-color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.btn-fixed-width {
width: 100px;
}
</style>