Files
dougal-software/lib/www/client/source/src/views/Log.vue

605 lines
17 KiB
Vue
Raw Normal View History

2020-08-08 23:59:13 +02:00
<template>
<v-container fluid>
<v-card>
<v-card-title>
<v-toolbar flat>
<v-toolbar-title>
<span class="d-none d-lg-inline">
{{
$route.params.sequence
? ($route.params.sequence.includes && $route.params.sequence.includes(";"))
? `Sequences ${$route.params.sequence.split(";").sort().join(", ")}`
: `Sequence ${$route.params.sequence}`
: $route.params.date0
? $route.params.date1
? `Between ${$route.params.date0} and ${$route.params.date1}`
: `On ${$route.params.date0}`
: "All events"
}}
</span>
<span class="d-lg-none">
{{
$route.params.sequence
? ($route.params.sequence.includes && $route.params.sequence.includes(";"))
? `${$route.params.sequence.split(";").sort().join(", ")}`
: `${$route.params.sequence}`
: $route.params.date0
? $route.params.date1
? `${$route.params.date0} ${$route.params.date1}`
: `${$route.params.date0}`
: ""
}}
</span>
</v-toolbar-title>
<dougal-event-edit v-if="writeaccess"
v-model="eventDialog"
v-bind="editedEvent"
:loading="loading"
:available-labels="userLabels"
:preset-remarks="presetRemarks"
@new="newEvent"
@changed="saveEvent"
>
</dougal-event-edit>
<dougal-event-edit-labels v-if="writeaccess"
v-model="eventLabelsDialog"
:labels="userLabels"
:selected="contextMenuItem ? contextMenuItem.labels||[] : []"
@selectionChanged="(data) => patchEvent(contextMenuItem.id, data)"
>
</dougal-event-edit-labels>
<v-menu v-if="$route.params.sequence">
<template v-slot:activator="{on, attrs}">
<v-btn class="ml-5" small v-on="on" v-bind="attrs">
<span class="d-none d-lg-inline">Download as</span>
<v-icon right small>mdi-cloud-download</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fvnd.seis%2Bjson`"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fgeo%2Bjson`"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fjson`"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=text%2Fhtml`"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fpdf`"
title="Download as a Portable Document File"
>PDF</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-text-field
v-model="filter"
append-icon="mdi-magnify"
2020-08-25 13:02:56 +02:00
label="Filter"
single-line
hide-details></v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
<v-data-table
:headers="headers"
:items="rows"
:items-per-page.sync="itemsPerPage"
2022-02-27 19:28:15 +01:00
item-key="key"
:item-class="(item) => (activeItem == item) ? 'align-top blue accent-1 elevation-3' : 'align-top'"
sort-by="tstamp"
:sort-desc="true"
:search="filter"
:custom-filter="searchTable"
:loading="loading"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
@click:row="setActiveItem"
>
<template v-slot:item.tstamp="{value}">
<span style="white-space:nowrap;" v-if="value">
{{ value.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2") }}
</span>
</template>
<template v-slot:item.remarks="{item}">
2022-02-27 19:28:15 +01:00
<template>
<div>
<div v-for="entry in item.items"
>
<span v-if="entry.labels.length">
<v-chip v-for="label in entry.labels"
class="mr-1 px-2 underline-on-hover"
x-small
:color="labels[label].view.colour"
:title="labels[label].view.description"
:key="label"
:href="$route.path+'?label='+encodeURIComponent(label)"
>{{label}}</v-chip>
</span>
<dougal-event-edit-history v-if="entry.has_edits"
:id="entry.id"
:disabled="loading"
:labels="labels"
></dougal-event-edit-history>
2022-02-27 19:28:15 +01:00
<span v-if="entry.meta.readonly"
class="entry text--secondary"
v-html="$options.filters.markdownInline(entry.remarks)">
</span>
<span v-else
class="entry underline-on-hover"
v-html="$options.filters.markdownInline(entry.remarks)">
</span>
</div>
</div>
</template>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
2020-08-08 23:59:13 +02:00
</template>
2022-02-27 19:28:15 +01:00
<style>
tr.align-top td:not(.align-default) {
vertical-align: top;
}
</style>
<style scoped>
.hover {
opacity: 0.4;
}
.hover:hover {
opacity: 1;
}
.hover.off:hover {
opacity: 0.4;
}
.underline-on-hover:hover {
text-decoration: underline;
}
.entry {
cursor: default;
}
.entry.underline-on-hover:hover {
text-decoration: underline;
}
</style>
2020-08-08 23:59:13 +02:00
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventEdit from '@/components/event-edit'
import DougalEventEditLabels from '@/components/event-edit-labels'
import DougalEventEditHistory from '@/components/event-edit-history'
function ArraysEqual (a, b) {
return a.every(i => b.includes(i)) && b.every(i => a.includes(i));
}
function EventKey (e) {
return e.type+e.id;
}
2020-08-08 23:59:13 +02:00
function clone (obj) {
return JSON.parse(JSON.stringify(obj));
}
2020-08-08 23:59:13 +02:00
export default {
name: "Log",
components: {
DougalEventEdit,
DougalEventEditLabels,
DougalEventEditHistory,
DougalContextMenu
},
data () {
return {
headers: [
{
value: "tstamp",
text: "Timestamp",
width: "20ex"
},
{
value: "sequence",
text: "Sequence",
align: "end",
width: "10ex"
},
{
value: "point",
text: "Shotpoint",
align: "end",
width: "10ex"
},
{
value: "remarks",
text: "Text",
2022-02-27 19:28:15 +01:00
width: "100%",
cellClass: "align-default"
}
],
items: [],
labels: {},
options: {},
filter: "",
eventCount: null,
eventDialog: false,
eventLabelsDialog: false,
defaultEventTimestamp: null,
presetRemarks: null,
remarksMenu: null,
remarksMenuItem: null,
editedEvent: {},
labelSearch: null,
queuedReload: false,
itemsPerPage: 25,
// Row highlighter
activeItem: null,
}
},
computed: {
rows () {
const rows = {};
this.items.forEach(i => {
2022-02-27 19:28:15 +01:00
const key = (i.sequence && i.point) ? (i.sequence+"@"+i.point) : i.tstamp;
if (!rows[key]) {
rows[key] = {
key,
tstamp: i.tstamp,
sequence: i.sequence,
point: i.point,
items: []
}
}
const row = rows[key];
row.items.push(i);
});
return Object.values(rows);
},
userLabels () {
const filtered = {};
for (const key in this.labels) {
if (this.labels[key].model.user) {
filtered[key] = this.labels[key];
}
}
return filtered;
},
popularLabels () {
const tuples = this.items.flatMap( i => i.labels )
.filter( l => (this.labels[l]??{})?.model?.user )
.reduce( (acc, cur) => {
return cur in acc ? ++acc[cur][1] : acc[cur]=[cur,1], acc
}, {});
return Object.values(tuples)
.sort( (a, b) => b[1]-a[1] );
},
2020-09-04 01:29:00 +02:00
defaultSequence () {
if (this.$route.params.sequence) {
return Number(this.$route.params.sequence.split(";").pop());
} else {
return this.sequence;
}
},
...mapGetters(['user', 'writeaccess', 'loading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'serverEvent']),
...mapState({projectSchema: state => state.project.projectSchema})
},
watch: {
options: {
handler () {
2020-10-03 00:36:53 +02:00
//this.getEvents();
},
deep: true
},
eventDialog (val) {
if (val) {
// If not online
this.defaultEventTimestamp = Date.now();
}
},
async serverEvent (event) {
if (event.channel == "event" && event.payload.schema == this.projectSchema) {
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
// already had time to update the cache by the time our request
// gets back to it.
this.getEvents();
} else {
this.queuedReload = true;
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
2020-10-03 00:36:53 +02:00
this.getEvents();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
2020-10-03 00:36:53 +02:00
this.getEvents();
}
},
itemsPerPage (newVal, oldVal) {
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
},
user (newVal, oldVal) {
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
}
},
methods: {
debug (value) {
console.log("DEBUG", value);
},
async getEventCount () {
//this.eventCount = await this.api([`/project/${this.$route.params.project}/event/?count`]);
this.eventCount = null;
},
async getEvents (opts = {}) {
const query = new URLSearchParams(this.options);
if (this.options.itemsPerPage < 0) {
query.delete("itemsPerPage");
}
if (this.$route.params.sequence) {
query.set("sequence", this.$route.params.sequence);
}
if (this.$route.params.date0) {
query.set("date0", this.$route.params.date0);
}
if (this.$route.params.date1) {
query.set("date1", this.$route.params.date1);
}
const url = `/project/${this.$route.params.project}/event?${query.toString()}`;
this.queuedReload = false;
this.items = await this.api([url, opts]) || [];
},
async getLabelDefinitions () {
const url = `/project/${this.$route.params.project}/label`;
const labelSet = {};
const labels = await this.api([url]) || [];
labels.forEach( l => labelSet[l.name] = l.data );
this.labels = labelSet;
},
async getPresetRemarks () {
const url = `/project/${this.$route.params.project}/configuration/events/presetRemarks`;
this.presetRemarks = await this.api([url]);
},
newItem (from = {}) {
const type = (from.sequence && from.point) ? "sequence" : "timed";
const tstamp = from.tstamp || (new Date).toISOString();
const sequence = from.sequence || null; // FIXME TODO Use vuex
const point = from.point || null; // FIXME TODO Use vuex
const geometry = from.geometry || null; // FIXME TODO Use vuex
return {
type,
id: null,
tstamp,
sequence,
point,
remarks: "",
labels: [],
geometry
};
},
/** Add a brand new event.
*
* Used when adding a new event to the database,
* assumed to have occurred in the immediate past,
* so we populate it with the last received real-time
* information (timestamp, shotpoint, position, etc.)
*/
newEvent () {
this.editedEvent = {
tstamp: this.timestamp,
sequence: this.sequence,
point: this.point,
longitude: (this.position??[])[0],
latitude: (this.position??[])[1]
}
},
async patchEvent (id, data) {
const callback = (err, res) => {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
}
}
const url = `/project/${this.$route.params.project}/event/${id}`;
await this.api([url, {
method: "PATCH",
body: data
}, callback]);
},
// TODO POST or PATCH depending on whether this is a new event
// (no id) or an edit (id present)
async saveEvent (event) {
2021-05-16 21:38:31 +02:00
const callback = (err, res) => {
if (!err && res.ok) {
this.showSnack(["Event saved", "success"]);
this.queuedReload = true;
this.getEvents({cache: "reload"});
}
}
if (event) {
if (event.id) {
const id = event.id;
delete event.id;
this.putEvent(id, event, callback); // No await
} else {
this.postEvent(event, callback); // No await
}
}
},
async putEvent (id, event, callback) {
const url = `/project/${this.$route.params.project}/event/${id}`;
await this.api([url, {
method: "PATCH",
body: event
}, callback]);
},
async postEvent (event, callback) {
const url = `/project/${this.$route.params.project}/event`;
await this.api([url, {
method: "POST",
body: event
}, callback]);
},
}
}
}
}
}
}
},
addPresetRemark ({text}) {
if (this.remarksMenuItem) {
this.remarksMenuItem.remarks = text;
}
this.remarksMenuItem = null;
},
availableLabels (usedLabels) {
return Object.keys(this.labels)
.filter(k => !usedLabels.includes(k) && this.labels[k].model.user);
},
handleKeyboardEvent (e) {
if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.keyCode == 13 || e.key == "F2")) {
2020-10-02 01:33:33 +02:00
// Add timed event if offline or shot event if online
this.eventDialog = true;
} else if (e.ctrlKey && !e.altKey && e.shiftKey && (e.keyCode == 13 || e.key == "F2")) {
2020-10-02 01:33:33 +02:00
// Add timed event (even if online)
this.eventDialog = true;
}
},
searchTable (value, search, item) {
if (!value && !search) return true;
const s = search.toLowerCase();
if (typeof value === 'string') {
return value.toLowerCase().includes(s);
} else if (typeof value === 'number') {
return value == search;
} else {
return item.items.some( i => i.remarks.toLowerCase().includes(s) ) ||
item.items.some( i => i.labels.some( l => l.toLowerCase().includes(s) ));
}
},
2022-02-27 19:28:15 +01:00
viewOnMap(item) {
if (item?.meta && item.meta?.geometry?.type == "Point") {
const [ lon, lat ] = item.meta.geometry.coordinates;
return `map#15/${lon.toFixed(6)}/${lat.toFixed(6)}`;
} else if (item?.items) {
return this.viewOnMap(item.items[0]);
}
},
setActiveItem (item) {
// Disable setting the active item for now,
// it's kind of annoying.
return;
/*
this.activeItem = this.activeItem == item
? null
: item;
*/
},
...mapActions(["api", "showSnack"])
},
async mounted () {
await this.getLabelDefinitions();
this.getEventCount();
this.getEvents();
this.getPresetRemarks();
window.addEventListener('keyup', this.handleKeyboardEvent);
},
beforeDestroy () {
window.removeEventListener('keyup', this.handleKeyboardEvent);
}
2020-08-08 23:59:13 +02:00
}
</script>