Files
dougal-software/lib/www/client/source/src/components/event-edit.vue
2022-05-12 23:38:39 +02:00

681 lines
18 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="!!(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"
hint="Placeholders: @DMS@, @DEG@, @EN@, @WD@, @BSP@, @CMG@, …"
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;
}
// The user is editing the remarks
if (this.entryRemarksText != this.entryRemarksInput) {
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 () {
// In case the focus goes directly from the remarks field
// to the Save button.
if (this.entryRemarksInput != this.entryRemarksText) {
this.entryRemarks = this.entryRemarksInput;
}
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>