mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 07:57:07 +00:00
670 lines
17 KiB
Vue
670 lines
17 KiB
Vue
<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="!!(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="!!(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">
|
|
<dougal-event-select
|
|
v-bind.sync="entryRemarks"
|
|
:preset-remarks="presetRemarks"
|
|
@update:labels="(v) => this.entryLabels = v"
|
|
></dougal-event-select>
|
|
</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-btn v-if="!id && (entrySequence || entryPoint)"
|
|
color="info"
|
|
text
|
|
title="Enter an event by time"
|
|
@click="timed"
|
|
>
|
|
<v-icon left small>mdi-clock-outline</v-icon>
|
|
Timed
|
|
</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';
|
|
import DougalEventSelect from '@/components/event-select';
|
|
|
|
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,
|
|
properties: item.properties,
|
|
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,
|
|
DougalEventSelect
|
|
},
|
|
|
|
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 },
|
|
meta: { type: Object },
|
|
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,
|
|
entryLatitude: null,
|
|
entryLongitude: null
|
|
}),
|
|
|
|
computed: {
|
|
|
|
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.entryLabels = [...(this.labels??[])];
|
|
this.makeEntryRemarks();
|
|
}
|
|
},
|
|
|
|
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
|
|
};
|
|
},
|
|
|
|
makeEntryRemarks () {
|
|
this.entryRemarks = {
|
|
template: null,
|
|
schema: {},
|
|
values: [],
|
|
...this.meta?.structured_values,
|
|
text: this.remarks
|
|
}
|
|
},
|
|
|
|
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;
|
|
}
|
|
}
|
|
},
|
|
|
|
timed () {
|
|
const tstamp = (new Date()).toISOString();
|
|
this.entrySequence = null;
|
|
this.entryPoint = null;
|
|
this.tsDate = tstamp.substr(0, 10);
|
|
this.tsTime = tstamp.substr(11, 8);
|
|
},
|
|
|
|
close () {
|
|
this.entryLabels = this.selectedLabels.map(this.labelToItem)
|
|
this.$emit("input", false);
|
|
},
|
|
|
|
save () {
|
|
// In case the focus goes directly from the remarks field
|
|
// to the Save button.
|
|
|
|
let meta;
|
|
|
|
if (this.entryRemarks.values?.length) {
|
|
meta = {
|
|
structured_values: {
|
|
template: this.entryRemarks.template,
|
|
schema: this.entryRemarks.schema,
|
|
values: this.entryRemarks.values
|
|
}
|
|
};
|
|
}
|
|
|
|
const data = {
|
|
id: this.id,
|
|
remarks: this.entryRemarksText,
|
|
labels: this.entryLabels,
|
|
meta
|
|
};
|
|
|
|
/* 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>
|