mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 11:57:08 +00:00
Add new event edit dialogue.
Replaces <dougal-event-edit-dialog/>.
This commit is contained in:
668
lib/www/client/source/src/components/event-edit.vue
Normal file
668
lib/www/client/source/src/components/event-edit.vue
Normal file
@@ -0,0 +1,668 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:value="value"
|
||||
@input="(e) => $emit('input', e)"
|
||||
max-width="600"
|
||||
>
|
||||
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn
|
||||
class="mx-2"
|
||||
fab dark
|
||||
x-small
|
||||
color="primary"
|
||||
title="Add event"
|
||||
@click="(e) => $emit('new', e)"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon dark>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-toolbar
|
||||
flat
|
||||
color="transparent"
|
||||
>
|
||||
<v-toolbar-title>Event</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
</v-toolbar>
|
||||
|
||||
<v-container class="py-0">
|
||||
|
||||
<v-row dense>
|
||||
<v-col>
|
||||
<v-menu
|
||||
v-model="dateMenu"
|
||||
:close-on-content-click="false"
|
||||
:nudge-right="40"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
min-width="auto"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-text-field
|
||||
v-model="tsDate"
|
||||
:disabled="!!(sequence || point || entrySequence || entryPoint)"
|
||||
label="Date"
|
||||
suffix="UTC"
|
||||
prepend-icon="mdi-calendar"
|
||||
readonly
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
@change="updateAncillaryData"
|
||||
></v-text-field>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="tsDate"
|
||||
@input="dateMenu = false"
|
||||
></v-date-picker>
|
||||
</v-menu>
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="tsTime"
|
||||
:disabled="!!(sequence || point || entrySequence || entryPoint)"
|
||||
label="Time"
|
||||
suffix="UTC"
|
||||
prepend-icon="mdi-clock-outline"
|
||||
type="time"
|
||||
step="1"
|
||||
@change="updateAncillaryData"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-menu
|
||||
v-model="timeMenu"
|
||||
:close-on-content-click="false"
|
||||
:nudge-right="40"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
min-width="auto"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon v-on="on" v-bind="attrs">mdi-clock-outline</v-icon>
|
||||
</template>
|
||||
<v-time-picker
|
||||
v-model="tsTime"
|
||||
format="24hr"
|
||||
></v-time-picker>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="entrySequence"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
label="Sequence"
|
||||
prepend-icon="mdi-format-list-bulleted"
|
||||
@change="updateAncillaryData"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="entryPoint"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
label="Point"
|
||||
prepend-icon="mdi-map-marker-circle"
|
||||
@change="updateAncillaryData"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<v-combobox
|
||||
ref="remarks"
|
||||
v-model="entryRemarks"
|
||||
:disabled="loading"
|
||||
:search-input.sync="entryRemarksInput"
|
||||
:items="remarksAvailable"
|
||||
:filter="searchRemarks"
|
||||
item-text="text"
|
||||
return-object
|
||||
label="Remarks"
|
||||
prepend-icon="mdi-text-box-outline"
|
||||
append-outer-icon="mdi-magnify"
|
||||
@click:append-outer="(e) => remarksMenu = e"
|
||||
></v-combobox>
|
||||
|
||||
<dougal-context-menu
|
||||
:value="remarksMenu"
|
||||
@input="handleRemarksMenu"
|
||||
:items="presetRemarks"
|
||||
absolute
|
||||
></dougal-context-menu>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
ref="labels"
|
||||
v-model="entryLabels"
|
||||
:items="categories"
|
||||
multiple
|
||||
menu-props="closeOnClick, closeOnContentClick"
|
||||
attach
|
||||
chips
|
||||
label="Labels"
|
||||
prepend-icon="mdi-tag-multiple"
|
||||
append-outer-icon="mdi-magnify"
|
||||
@click:append-outer="() => $refs.labels.focus()"
|
||||
>
|
||||
|
||||
<template v-slot:selection="{ item, index, select, selected, disabled }">
|
||||
<v-chip
|
||||
:disabled="loading"
|
||||
small
|
||||
light
|
||||
:color="item.colour"
|
||||
:title="item.title"
|
||||
close
|
||||
@click:close="entryLabels.splice(index, 1)"
|
||||
>
|
||||
<v-icon
|
||||
left
|
||||
v-text="item.icon"
|
||||
></v-icon>
|
||||
{{ item.text }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item="{ item }">
|
||||
<v-list-item-avatar
|
||||
class="my-0"
|
||||
width="12ex"
|
||||
>
|
||||
<v-chip
|
||||
x-small
|
||||
light
|
||||
:color="item.colour"
|
||||
:title="item.title"
|
||||
>{{item.text}}</v-chip>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-title v-text="item.title"></v-list-item-title>
|
||||
</template>
|
||||
|
||||
</v-autocomplete>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="entryLatitude"
|
||||
label="Latitude"
|
||||
prepend-icon="φ"
|
||||
disabled
|
||||
>
|
||||
<template v-slot:append-outer>
|
||||
<v-icon v-if="false/*TODO*/"
|
||||
title="Click to set position"
|
||||
@click="1==1/*TODO*/"
|
||||
>mdi-crosshairs-gps</v-icon>
|
||||
<v-icon v-else
|
||||
disabled
|
||||
title="No GNSS available"
|
||||
>mdi-crosshairs</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="entryLongitude"
|
||||
label="Longitude"
|
||||
prepend-icon="λ"
|
||||
disabled
|
||||
>
|
||||
<template v-slot:append-outer>
|
||||
<v-icon v-if="false"
|
||||
title="Click to set position"
|
||||
@click="getPosition"
|
||||
>mdi-crosshairs-gps</v-icon>
|
||||
<v-icon v-else
|
||||
title="No GNSS available"
|
||||
disabled
|
||||
>mdi-crosshairs</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="warning"
|
||||
text
|
||||
@click="close"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
:disabled="!canSave"
|
||||
:loading="loading"
|
||||
color="primary"
|
||||
text
|
||||
@click="save"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* https://github.com/vuetifyjs/vuetify/issues/471 */
|
||||
.v-dialog {
|
||||
overflow-y: initial;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import DougalContextMenu from '@/components/context-menu';
|
||||
|
||||
function stringSort (a, b) {
|
||||
return a == b
|
||||
? 0
|
||||
: a < b
|
||||
? -1
|
||||
: +1;
|
||||
}
|
||||
|
||||
|
||||
function flattenRemarks(items, keywords=[], labels=[]) {
|
||||
const result = [];
|
||||
|
||||
if (items) {
|
||||
for (const item of items) {
|
||||
if (!item.items) {
|
||||
result.push({
|
||||
text: item.text,
|
||||
labels: labels.concat(item.labels??[]),
|
||||
keywords
|
||||
})
|
||||
} else {
|
||||
const k = [...keywords, item.text];
|
||||
const l = [...labels, ...(item.labels??[])];
|
||||
result.push(...flattenRemarks(item.items, k, l))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare two arrays
|
||||
*
|
||||
* @a a First array
|
||||
* @a b Second array
|
||||
* @a cbB Callback to transform elements of `b`
|
||||
*
|
||||
* @return true if the sets are distinct, false otherwise
|
||||
*
|
||||
* Note that this will not work with object or other complex
|
||||
* elements unless the array members are the same object (as
|
||||
* opposed to merely identical).
|
||||
*/
|
||||
function distinctSets(a, b, cbB = (i) => i) {
|
||||
return !a.every(i => b.map(cbB).includes(i)) ||
|
||||
!b.map(cbB).every(i => a.find(j => j==i));
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'DougalEventEdit',
|
||||
|
||||
components: {
|
||||
DougalContextMenu
|
||||
},
|
||||
|
||||
props: {
|
||||
value: { default: false },
|
||||
availableLabels: { type: Object, default: () => ({}) },
|
||||
presetRemarks: { type: Array, default: () => [] },
|
||||
id: { type: Number },
|
||||
tstamp: { type: String },
|
||||
sequence: { type: Number },
|
||||
point: { type: Number },
|
||||
remarks: { type: String },
|
||||
labels: { type: Array, default: () => [] },
|
||||
latitude: { type: Number },
|
||||
longitude: { type: Number },
|
||||
loading: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
dateMenu: false,
|
||||
timeMenu: false,
|
||||
remarksMenu: false,
|
||||
search: '',
|
||||
entryLabels: [],
|
||||
tsDate: null,
|
||||
tsTime: null,
|
||||
entrySequence: null,
|
||||
entryPoint: null,
|
||||
entryRemarks: null,
|
||||
entryRemarksInput: null,
|
||||
entryLatitude: null,
|
||||
entryLongitude: null
|
||||
}),
|
||||
|
||||
computed: {
|
||||
remarksAvailable () {
|
||||
return this.entryRemarksInput == this.entryRemarks?.text ||
|
||||
this.entryRemarksInput == this.entryRemarks
|
||||
? []
|
||||
: flattenRemarks(this.presetRemarks);
|
||||
},
|
||||
|
||||
allSelected () {
|
||||
return this.entryLabels.length === this.items.length
|
||||
},
|
||||
|
||||
dirty () {
|
||||
// Selected remark distinct from input remark
|
||||
if (this.entryRemarksText != this.remarks) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Selected label set distinct from input labels
|
||||
if (distinctSets(this.selectedLabels, this.entryLabels, (i) => i.text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Selected seqpoint distinct from input seqpoint (if seqpoint present)
|
||||
if ((this.entrySequence || this.entryPoint)) {
|
||||
if (this.entrySequence != this.sequence || this.entryPoint != this.point) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Selected timestamp distinct from input timestamp (if no seqpoint)
|
||||
const epoch = Date.parse(this.tstamp);
|
||||
const entryEpoch = Date.parse(`${this.tsDate} ${this.tsTime}Z`);
|
||||
// Ignore difference of less than one second
|
||||
if (Math.abs(entryEpoch - epoch) > 1000) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
canSave () {
|
||||
// There is either tstamp or seqpoint, latter wins
|
||||
if (!(this.entrySequence && this.entryPoint) && !this.entryTstamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// There are remarks and/or labels
|
||||
if (!this.entryRemarksText && !this.entryLabels.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Form is dirty
|
||||
if (!this.dirty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
categories () {
|
||||
const search = this.search.toLowerCase()
|
||||
|
||||
if (!search) return this.items
|
||||
|
||||
return this.items.filter(item => {
|
||||
const text = item.text.toLowerCase();
|
||||
const title = item.title.toLowerCase();
|
||||
|
||||
return text.includes(search) || title.includes(search);
|
||||
}).sort( (a, b) => stringSort(a.text, b.text) )
|
||||
},
|
||||
|
||||
items () {
|
||||
return Object.keys(this.availableLabels).map(this.labelToItem);
|
||||
},
|
||||
|
||||
selectedLabels () {
|
||||
return this.event?.labels ?? [];
|
||||
},
|
||||
|
||||
entryTstamp () {
|
||||
const ts = new Date(Date.parse(`${this.tsDate} ${this.tsTime}Z`));
|
||||
if (isNaN(ts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ts.toISOString();
|
||||
},
|
||||
|
||||
entryRemarksText () {
|
||||
return typeof this.entryRemarks === 'string'
|
||||
? this.entryRemarks
|
||||
: this.entryRemarks?.text;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
value () {
|
||||
if (this.value) {
|
||||
// Populate fields from properties
|
||||
if (!this.tstamp && !this.sequence && !this.point) {
|
||||
const ts = (new Date()).toISOString();
|
||||
this.tsDate = ts.substr(0, 10);
|
||||
this.tsTime = ts.substr(11, 8);
|
||||
} else if (this.tstamp) {
|
||||
this.tsDate = this.tstamp.substr(0, 10);
|
||||
this.tsTime = this.tstamp.substr(11, 8);
|
||||
}
|
||||
|
||||
// NOTE Dead code
|
||||
if (this.meta?.geometry?.type == "Point") {
|
||||
this.entryLongitude = this.meta.geometry.coordinates[0];
|
||||
this.entryLatitude = this.meta.geometry.coordinates[1];
|
||||
}
|
||||
|
||||
this.entryLatitude = this.latitude;
|
||||
this.entryLongitude = this.longitude;
|
||||
|
||||
this.entrySequence = this.sequence;
|
||||
this.entryPoint = this.point;
|
||||
this.entryRemarks = this.remarks;
|
||||
this.entryLabels = [...(this.labels??[])];
|
||||
|
||||
// Focus remarks field
|
||||
this.$nextTick(() => this.$refs.remarks.focus());
|
||||
}
|
||||
},
|
||||
|
||||
tstamp () {
|
||||
if (this.tstamp) {
|
||||
this.tsDate = this.tstamp.substr(0, 10);
|
||||
this.tsTime = this.tstamp.substr(11, 8);
|
||||
} else if (this.sequence || this.point) {
|
||||
this.tsDate = null;
|
||||
this.tsTime = null;
|
||||
} else {
|
||||
const ts = (new Date()).toISOString();
|
||||
this.tsDate = ts.substr(0, 10);
|
||||
this.tsTime = ts.substr(11, 8);
|
||||
}
|
||||
},
|
||||
|
||||
sequence () {
|
||||
if (this.sequence && !this.tstamp) {
|
||||
this.tsDate = null;
|
||||
this.tsTime = null;
|
||||
}
|
||||
},
|
||||
|
||||
point () {
|
||||
if (this.point && !this.tstamp) {
|
||||
this.tsDate = null;
|
||||
this.tsTime = null;
|
||||
}
|
||||
},
|
||||
|
||||
entryTstamp (n, o) {
|
||||
//this.updateAncillaryData();
|
||||
},
|
||||
|
||||
entrySequence (n, o) {
|
||||
//this.updateAncillaryData();
|
||||
},
|
||||
|
||||
entryPoint (n, o) {
|
||||
//this.updateAncillaryData();
|
||||
},
|
||||
|
||||
entryRemarks () {
|
||||
if (this.entryRemarks?.labels) {
|
||||
this.entryLabels = [...this.entryRemarks.labels];
|
||||
} else if (!this.entryRemarks) {
|
||||
this.entryLabels = [];
|
||||
}
|
||||
},
|
||||
|
||||
selectedLabels () {
|
||||
this.entryLabels = this.selectedLabels.map(this.labelToItem)
|
||||
},
|
||||
|
||||
entryLabels () {
|
||||
this.search = '';
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
labelToItem (k) {
|
||||
return {
|
||||
text: k,
|
||||
icon: this.availableLabels[k].view?.icon,
|
||||
colour: this.availableLabels[k].view?.colour,
|
||||
title: this.availableLabels[k].view?.description
|
||||
};
|
||||
},
|
||||
|
||||
searchRemarks (item, queryText, itemText) {
|
||||
const needle = queryText.toLowerCase();
|
||||
const text = item.text.toLowerCase();
|
||||
const keywords = item.keywords.map(i => i.toLowerCase());
|
||||
const labels = item.labels.map(i => i.toLowerCase());
|
||||
return text.includes(needle) ||
|
||||
keywords.some(i => i.includes(needle)) ||
|
||||
labels.some(i => i.includes(needle));
|
||||
},
|
||||
|
||||
handleRemarksMenu (event) {
|
||||
if (typeof event == 'boolean') {
|
||||
this.remarksMenu = event;
|
||||
} else {
|
||||
this.entryRemarks = event;
|
||||
this.remarksMenu = false;
|
||||
}
|
||||
},
|
||||
|
||||
async getPointData () {
|
||||
const url = `/project/${this.$route.params.project}/sequence/${this.entrySequence}/${this.entryPoint}`;
|
||||
return await this.api([url]);
|
||||
},
|
||||
|
||||
async getTstampData () {
|
||||
const url = `/navdata?q=tstamp:${this.entryTstamp}&tolerance:2500`;
|
||||
return await this.api([url]);
|
||||
},
|
||||
|
||||
async updateAncillaryData () {
|
||||
if (this.entrySequence && this.entryPoint) {
|
||||
// Fetch data for this sequence / point
|
||||
const data = await this.getPointData();
|
||||
|
||||
if (data?.tstamp) {
|
||||
this.tsDate = data.tstamp.substr(0, 10);
|
||||
this.tsTime = data.tstamp.substr(11, 8);
|
||||
}
|
||||
|
||||
if (data?.geometry) {
|
||||
this.entryLongitude = (data?.geometry?.coordinates??[])[0];
|
||||
this.entryLatitude = (data?.geometry?.coordinates??[])[1];
|
||||
}
|
||||
} else if (!this.entrySequence && !this.entryPoint && this.entryTstamp) {
|
||||
// Fetch data for this timestamp
|
||||
const data = ((await this.getTstampData())??[])[0];
|
||||
console.log("TS DATA", data);
|
||||
if (data?._sequence && data?.shot) {
|
||||
this.entrySequence = Number(data._sequence);
|
||||
this.entryPoint = data.shot;
|
||||
}
|
||||
|
||||
if (data?.tstamp) {
|
||||
this.tsDate = data.tstamp.substr(0, 10);
|
||||
this.tsTime = data.tstamp.substr(11, 8);
|
||||
}
|
||||
|
||||
if (data?.longitude && data?.latitude) {
|
||||
this.entryLongitude = data.longitude;
|
||||
this.entryLatitude = data.latitude;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
close () {
|
||||
this.entryLabels = this.selectedLabels.map(this.labelToItem)
|
||||
this.$emit("input", false);
|
||||
},
|
||||
|
||||
save () {
|
||||
const data = {
|
||||
id: this.id,
|
||||
remarks: this.entryRemarksText,
|
||||
labels: this.entryLabels
|
||||
};
|
||||
|
||||
/* NOTE This is the purist way.
|
||||
* Where we expect that the server will match
|
||||
* timestamps with shotpoints and so on
|
||||
*
|
||||
if (this.entrySequence && this.entryPoint) {
|
||||
data.sequence = this.entrySequence;
|
||||
data.point = this.entryPoint;
|
||||
} else {
|
||||
data.tstamp = this.entryTstamp;
|
||||
}
|
||||
*/
|
||||
|
||||
/* NOTE And this is the pragmatic way.
|
||||
*/
|
||||
data.tstamp = this.entryTstamp;
|
||||
if (this.entrySequence && this.entryPoint) {
|
||||
data.sequence = this.entrySequence;
|
||||
data.point = this.entryPoint;
|
||||
}
|
||||
|
||||
this.$emit("changed", data);
|
||||
this.$emit("input", false);
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
},
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user