Compare commits

..

30 Commits

Author SHA1 Message Date
D. Berge
cf887b7852 Upgrade Plotly 2023-10-31 19:49:40 +01:00
D. Berge
c201229891 Add a link from the event log to the shotlog 2023-10-31 19:47:18 +01:00
D. Berge
7ac997cd7d Add a link from the shotlog to the event log 2023-10-31 19:46:40 +01:00
D. Berge
08e6c4a2de Restyle sequence list links to shot and event logs 2023-10-31 19:46:07 +01:00
D. Berge
2c21f8f7ef Add some graphics to the shotlog 2023-10-31 19:15:43 +01:00
D. Berge
a76aefe418 Add graphing component for shotpoint timing visualisations.
It operates in one of these modes:

* facet="bars" (default): shows a barplot.

* facet="lines": shows a lineplot.

* facet="area": shows a lineplot where the area between the
  line(s) and y=0 is filled with a colour.
2023-10-31 19:11:09 +01:00
D. Berge
8d825fc53b Add graphing component for inline/crossline visualisations.
The component takes a list of shots and operates in one of these
modes:

* facet="scatter" (default): shows a scatterplot of every shot
  where x is the crossline and y are the inline errors.

* facet="crossline": shows a line graph depicting the crossline
  error along the line, x is the shotpoint and y is the crossline
  error.

* facet="2dhist": shows the crossline error as a 2D histogram.
  The z value is the density (number of samples in the bin) and
  x and y are the bin centres.

* facet="c-o": provided that the shot data comes from a final
  sequence, shows the difference between final and raw positions
  along the inline / crossline axes.
2023-10-31 19:04:19 +01:00
D. Berge
b039a5f1fd Expand unpack() to be more expressive 2023-10-31 19:03:10 +01:00
D. Berge
5c1218e95e Add link to shotlog from sequence list 2023-10-31 10:34:51 +01:00
D. Berge
1bb5e2a41d Implement the SequenceSummary component as a shotlog 2023-10-31 10:33:56 +01:00
D. Berge
1576b121e6 Force dev frontend to run on IPv4 2023-10-29 20:46:11 +01:00
D. Berge
a06cdde449 Fix mapGetters() in ProjectList 2023-10-29 20:38:58 +01:00
D. Berge
121131e910 Add control to filter out archived projects in ProjectList 2023-10-29 20:38:58 +01:00
D. Berge
9136e9655d Return the archived project configuration value.
This value indicates whether the project should receive updates
from external sources.
2023-10-29 20:38:58 +01:00
D. Berge
c646944886 Add download control for all events to Log view.
The log until now offered a download control only in sequence
view mode. With this change, download is available (albeit not
in all formats) for the entire log.

To download events for a selection of dates (constrained by day,
week or month) the user should use the Calendar view instead.
2023-10-29 20:38:58 +01:00
D. Berge
0e664fc095 Add download control to Calendar view.
Will download the event log for the currently selected calendar
period (day, week, month, …) in a choice of formats.
2023-10-29 20:38:58 +01:00
D. Berge
1498891004 Update API specification 2023-10-29 20:38:58 +01:00
D. Berge
89cb237f8d Use setContentDisposition() 2023-10-29 20:38:58 +01:00
D. Berge
3386c57670 Add setContentDisposition() utility function.
It checks if a request has a `filename` search parameter and if
so, set the Content-Disposition response header to attachment
with the provided filename.
2023-10-29 20:38:58 +01:00
D. Berge
7285de5ec4 Import projectConfiguration getter into Log view.
So that we can get hold of `events.presetRemarks`.
2023-10-29 20:38:58 +01:00
D. Berge
a95059f5e5 Change navigation bar aesthetics 2023-10-29 20:38:58 +01:00
D. Berge
1ac81c34ce Add structured values support to <dougal-event-edit/> 2023-10-29 20:38:58 +01:00
D. Berge
22387ba215 Add <dougal-event-select/> component.
This is a refactoring of <dougal-event-edit/> focusing on the
preset remark selection combo box and context menu with the
addition of support for structured values via the
<dougal-event-properties/> component.
2023-10-29 20:38:58 +01:00
D. Berge
b77d41e952 Add <dougal-event-properties/> component.
It provides an input form for structured values.
2023-10-29 20:38:58 +01:00
D. Berge
aeecb7db7d Replace hard-coded navigation bar with dynamic alternative.
Navigation bars should be coded as their own component and
added to the meta section of the Vue Router's route(s) in which
they are to be used.
2023-10-29 20:38:58 +01:00
D. Berge
ac9a683135 Add <v-app-bar/> extension info to router.
The idea is for the <dougal-navigation/> component to dynamically
load the extension, if any, defined in the route's meta attribute.
2023-10-29 20:38:58 +01:00
D. Berge
17a58f1396 Create new <v-app-bar/> extension component.
Intended to be used in the v-slot:extension slot of <v-app-bar/>.
2023-10-29 20:38:58 +01:00
D. Berge
b2a97a1987 Fix typo in SQL query.
Fixes #284.
2023-10-29 20:38:58 +01:00
D. Berge
f684e3e8d6 Track changes to projects list.
The main application component listens for project events and
updates the Vuex store when a project related event is seen.
2023-10-29 20:38:58 +01:00
D. Berge
219425245f Add Vuex projects module.
Not to be confused with the `project` module.

`projects`: lists all available projects
`project`: lists details for one project
2023-10-29 20:38:58 +01:00
22 changed files with 1555 additions and 25 deletions

View File

@@ -18,7 +18,7 @@
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"marked": "^2.0.3",
"plotly.js-dist": "^2.5.0",
"plotly.js-dist": "^2.27.0",
"suncalc": "^1.8.0",
"typeface-roboto": "0.0.75",
"vue": "^2.6.12",
@@ -10317,9 +10317,9 @@
}
},
"node_modules/plotly.js-dist": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.11.1.tgz",
"integrity": "sha512-TubG71bBueWRMkQQMGlG8/0q753x5LLXzEHieUt9s0M/nD7WpnjC7sEwH+flhN6dOLqQ2wGlOb8Z4A3ah4IDWg=="
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.27.0.tgz",
"integrity": "sha512-SuuIF6zpJpW+9ssgXghMdcaJ9mp06SATykpxBVSrX9PpX9YZpCg6oo/79WvjYUVHf33QreJ4csAJU3o5Ll32cQ=="
},
"node_modules/pnp-webpack-plugin": {
"version": "1.7.0",
@@ -23254,9 +23254,9 @@
}
},
"plotly.js-dist": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.11.1.tgz",
"integrity": "sha512-TubG71bBueWRMkQQMGlG8/0q753x5LLXzEHieUt9s0M/nD7WpnjC7sEwH+flhN6dOLqQ2wGlOb8Z4A3ah4IDWg=="
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.27.0.tgz",
"integrity": "sha512-SuuIF6zpJpW+9ssgXghMdcaJ9mp06SATykpxBVSrX9PpX9YZpCg6oo/79WvjYUVHf33QreJ4csAJU3o5Ll32cQ=="
},
"pnp-webpack-plugin": {
"version": "1.7.0",

View File

@@ -16,7 +16,7 @@
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"marked": "^2.0.3",
"plotly.js-dist": "^2.5.0",
"plotly.js-dist": "^2.27.0",
"suncalc": "^1.8.0",
"typeface-roboto": "0.0.75",
"vue": "^2.6.12",

View File

@@ -0,0 +1,290 @@
<template>
<div ref="graph"
class="graph-container"
></div>
</template>
<style scoped>
.graph-container {
width: 100%;
height: 100%;
}
</style>
<script>
import Plotly from 'plotly.js-dist';
import unpack from '@/lib/unpack.js';
export default {
name: "DougalGraphProjectSequenceInlineCrossline",
props: {
items: Array,
gunDataFormat: { type: String, default: "smsrc" },
facet: { type: String, default: "scatter" }
},
data () {
return {
plotted: false,
resizeObserver: null
};
},
computed: {
config () {
switch (this.facet) {
case "scatter":
default:
return {
editable: false,
displayLogo: false
};
}
},
layout () {
const base = {
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
}
};
switch (this.facet) {
case "scatter":
return {
...base,
autocolorscale: true,
title: {text: `Preplot deviation <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m; y̅: %{data[0].meta.avg_y} ±%{data[0].meta.std_y} m)</span>`},
xaxis: {
title: "Crossline (m)"
},
yaxis: {
title: "Inline (m)"
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
case "crossline":
return {
...base,
autocolorscale: true,
title: {text: `Crossline deviation <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m)</span>`},
xaxis: {
title: "Shotpoint"
},
yaxis: {
title: "Crossline (m)"
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
case "2dhist":
return {
...base,
showlegend: true,
title: {text: `Preplot deviation <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m; y̅: %{data[0].meta.avg_y} ±%{data[0].meta.std_y} m)</span>`},
xaxis: {
title: "Crossline (m)",
showgrid: true,
zeroline: true
},
yaxis: {
title: "Inline (m)",
showgrid: true,
zeroline: true
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
case "c-o":
return {
...base,
showlegend: true,
title: {text: `Final vs raw <span style="font-size:smaller;">(x̅: %{data[0].meta.avg_x} ±%{data[0].meta.std_x} m; y̅: %{data[0].meta.avg_y} ±%{data[0].meta.std_y} m)</span>`},
xaxis: {
title: "Crossline (m)",
showgrid: true,
zeroline: true
},
yaxis: {
title: "Inline (m)",
showgrid: true,
zeroline: true
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
}
},
data () {
if (!this.items?.length) {
return [];
}
let x, y, avg_x, avg_y, std_x, std_y;
const items = this.items.sort( (a, b) => a.point - b.point );
const meta = unpack(items, "meta");
const src_number = unpack(unpack(unpack(meta, "raw"), this.gunDataFormat), "src_number");
if (this.facet == "c-o") {
const _items = items.filter(i => i.errorfinal && i.errorraw);
const εf = unpack(unpack(_items, "errorfinal"), "coordinates");
const εr = unpack(unpack(_items, "errorraw"), "coordinates");
x = εf.map( (f, idx) => f[0] - εr[idx][0] )
y = εf.map( (f, idx) => f[1] - εr[idx][1] )
} else {
const coords = unpack(unpack(items, ((row) => row?.errorfinal ? row.errorfinal : row.errorraw)), "coordinates");
x = unpack(coords, 0);
y = unpack(coords, 1);
}
// No chance of overflow
avg_x = (x.reduce((acc, cur) => acc + cur, 0) / x.length).toFixed(2);
avg_y = (y.reduce((acc, cur) => acc + cur, 0) / y.length).toFixed(2);
std_x = Math.sqrt(x.reduce((acc, cur) => (cur-avg_x)**2 + acc, 0) / x.length).toFixed(2);
std_y = Math.sqrt(y.reduce((acc, cur) => (cur-avg_y)**2 + acc, 0) / y.length).toFixed(2);
if (this.facet == "scatter") {
const data = [{
type: "scatter",
mode: "markers",
x,
y,
meta: { avg_x, avg_y, std_x, std_y},
transforms: [{
type: "groupby",
groups: src_number,
styles: [
{target: 1, value: {line: {color: "green"}}},
{target: 2, value: {line: {color: "red"}}},
{target: 3, value: {line: {color: "blue"}}}
]
}],
}];
return data;
} else if (this.facet == "crossline") {
const s = unpack(items, "point");
const data = [{
type: "scatter",
x: s,
y: x,
meta: { avg_x, avg_y, std_x, std_y},
_transforms: [{
type: "groupby",
groups: src_number,
styles: [
{target: 1, value: {line: {color: "green"}}},
{target: 2, value: {line: {color: "red"}}},
{target: 3, value: {line: {color: "blue"}}}
]
}],
}];
return data;
} else if (this.facet == "2dhist" || this.facet == "c-o") {
const bottomValue = this.$vuetify.theme.isDark
? ['0.0', 'rgba(0,0,0,0)']
: ['0.0', 'rgb(165,0,38)'];
const topValue = this.$vuetify.theme.isDark
? ['1.0', 'rgb(49,54,149)']
: ['1.0', 'rgba(0,0,0,0)'];
const colourscale = this.facet == "c-o"
? [bottomValue, [0.1, 'rgb(0,0,0)'], [0.9, 'rgb(255,255,255)'], topValue]
: [
bottomValue,
['0.111111111111', 'rgb(215,48,39)'],
['0.222222222222', 'rgb(244,109,67)'],
['0.333333333333', 'rgb(253,174,97)'],
['0.444444444444', 'rgb(254,224,144)'],
['0.555555555556', 'rgb(224,243,248)'],
['0.666666666667', 'rgb(171,217,233)'],
['0.777777777778', 'rgb(116,173,209)'],
['0.888888888889', 'rgb(69,117,180)'],
topValue
];
const data = [{
type: "histogram2dcontour",
ncontours: 20,
colorscale: colourscale,
showscale: false,
reversescale: !this.$vuetify.theme.isDark,
contours: {
coloring: this.facet == "c-o" ? "fill" : "heatmap",
},
x,
y,
meta: { avg_x, avg_y, std_x, std_y}
}];
return data;
}
}
},
watch: {
items (cur, prev) {
if (cur != prev) {
this.plot();
}
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
methods: {
plot () {
Plotly.newPlot(this.$refs.graph, this.data, this.layout, this.config);
this.plotted = true;
},
replot () {
if (this.plotted) {
const ref = this.$refs.graph;
Plotly.relayout(ref, {
width: ref.clientWidth,
height: ref.clientHeight
});
}
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.replot)
this.resizeObserver.observe(this.$refs.graph);
},
beforeDestroy () {
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.$refs.graph);
}
}
}
</script>

View File

@@ -0,0 +1,196 @@
<template>
<div ref="graph"
class="graph-container"
></div>
</template>
<style scoped>
.graph-container {
width: 100%;
height: 100%;
}
</style>
<script>
import Plotly from 'plotly.js-dist';
import unpack from '@/lib/unpack.js';
export default {
name: "DougalGraphProjectSequenceShotpointTiming",
props: {
items: Array,
gunDataFormat: { type: String, default: "smsrc" },
facet: { type: String, default: "bars" }
},
data () {
return {
plotted: false,
resizeObserver: null
};
},
computed: {
config () {
return {
editable: false,
displayLogo: false
};
},
layout () {
return {
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
title: {text: "Shotpoint timing %{data[0].meta.subtitle}"},
xaxis: {
title: "Shotpoint"
},
yaxis: {
title: "Time (s)"
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
};
},
data () {
const items = this.items.map(i => {
return {
point: i.point,
tstamp: new Date(i.tstamp)
}
}).sort( (a, b) => a.tstamp - b.tstamp );
const x = [...unpack(items, "point")];
const y = items.map( (i, idx, ary) => (ary[idx+1]?.tstamp - i.tstamp)/1000 );
const src_number = unpack(this.items, ["meta", "raw", this.gunDataFormat, "src_number"]);
// We're dealing with intervals not points
x.pop(); y.pop(); src_number.pop();
const meta = {};
const stats = this.stats(x, y, src_number);
// We need to do the subtitle here rather than in layout as layout knows nothing
// about the number of arrays
if (stats.src_ids.length == 1) {
meta.subtitle = `<span style="font-size:smaller;">(μ = ${stats.avg.all.toFixed(2)} ±${stats.std.all.toFixed(2)} s)</span>`;
} else {
meta.subtitle = `<span style="font-size:smaller;">(μ = ${stats.avg.all.toFixed(2)} ±${stats.std.all.toFixed(2)} s)</span>`;
const per_source = [];
for (const key in stats.avg) {
if (key == "all") continue;
const s = `μ<sub>${key}</sub> = ${stats.avg[key].toFixed(2)} ±${stats.std[key].toFixed(2)} s`;
per_source.push(s);
}
meta.subtitle += `<br><span style="font-size:smaller;">` + per_source.join("; ") + "</span>";
}
const trace0 = {
type: "bar",
x,
y,
transforms: [{
type: "groupby",
groups: src_number,
styles: [
{target: 1, value: {line: {color: "green"}}},
{target: 2, value: {line: {color: "red"}}},
{target: 3, value: {line: {color: "blue"}}}
]
}],
meta
};
switch (this.facet) {
case "lines":
trace0.type = "scatter";
break;
case "area":
trace0.type = "scatter";
trace0.fill = "tozeroy";
break;
case "bars":
default:
// Nothing
}
return [trace0]
}
},
watch: {
items (cur, prev) {
if (cur != prev) {
this.plot();
}
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
methods: {
plot () {
Plotly.newPlot(this.$refs.graph, this.data, this.layout, this.config);
this.plotted = true;
},
replot () {
if (this.plotted) {
const ref = this.$refs.graph;
Plotly.relayout(ref, {
width: ref.clientWidth,
height: ref.clientHeight
});
}
},
stats (x, y, src_number) {
const avg = {};
const std = {};
const avg_all = (y.reduce((acc, cur) => acc + cur, 0) / y.length);
const std_all = Math.sqrt(y.reduce((acc, cur) => (cur-avg_all)**2 + acc, 0) / y.length);
avg.all = avg_all;
std.all = std_all;
const src_ids = new Set(src_number);
for (const src of src_ids) {
const v = y.filter((i, idx) => src_number[idx] == src);
const μ = (v.reduce((acc, cur) => acc + cur, 0) / v.length);
const σ = Math.sqrt(v.reduce((acc, cur) => (cur-μ)**2 + acc, 0) / v.length);
avg[src] = μ;
std[src] = σ;
}
return { avg, std, src_ids };
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.replot)
this.resizeObserver.observe(this.$refs.graph);
},
beforeDestroy () {
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.$refs.graph);
}
}
}
</script>

View File

@@ -3,6 +3,7 @@
<v-app-bar
app
clipped-left
elevation="1"
>
<v-img src="/wgp-logo.png"
contain

View File

@@ -1,4 +1,33 @@
/** Unpacks attributes from array items.
*
* At it simplest, given an array of objects,
* the call unpack(rows, "x") returns an array
* of the "x" attribute of every item in rows.
*
* `key` may also be:
*
* - a function with the signature
* (Object) => any
* the result of applying the function to
* the object will be used as the unpacked
* value.
*
* - an array of strings, functions or other
* arrays. In this case, it does a recursive
* fold operation. NOTE: it mutates `key`.
*
*/
export default function unpack(rows, key) {
return rows && rows.map( row => row[key] );
if (typeof key === "function") {
return rows && rows.map( row => key(row) );
} else if (Array.isArray(key)) {
const car = key.shift();
if (key.length) {
return unpack(unpack(rows, car), key);
} else {
return unpack(rows, car);
}
} else {
return rows && rows.map( row => row?.[key] );
}
};

View File

@@ -4,6 +4,7 @@ import Vuex from 'vuex'
import api from './modules/api'
import user from './modules/user'
import snack from './modules/snack'
import projects from './modules/projects'
import project from './modules/project'
import event from './modules/event'
import label from './modules/label'
@@ -19,6 +20,7 @@ export default new Vuex.Store({
api,
user,
snack,
projects,
project,
event,
label,

View File

@@ -0,0 +1,120 @@
/** Fetch projects from server
*/
async function refreshProjects ({commit, dispatch, state, rootState}) {
if (state.loading) {
commit('abortProjectsLoading');
}
commit('setProjectsLoading');
const pid = rootState.project.projectId;
const url = `/project`;
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
if (res) {
commit('setProjects', res);
commit('setProjectsTimestamp');
}
commit('clearProjectsLoading');
}
/** Return a subset of projects from state.projects
*/
async function getProjects ({commit, dispatch, state}, [{pid, name, schema, group, sortBy, sortDesc, itemsPerPage, page, text}] = [{}]) {
let filteredProjects = [...state.projects];
if (sortBy) {
sortBy.forEach( (key, idx) => {
filteredProjects.sort( (el0, el1) => {
const a = el0?.[key];
const b = el1?.[key];
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else if (a == b) {
return 0;
} else if (a && !b) {
return 1;
} else if (!a && b) {
return -1;
} else {
return 0;
}
});
if (sortDesc && sortDesc[idx] === true) {
filteredProjects.reverse();
}
});
}
if (pid) {
filteredProjects = filteredProjects.filter( project => project.pid == pid );
}
if (name) {
filteredProjects = filteredProjects.filter( project => project.name.toLowerCase().includes(name.toLowerCase()) );
}
if (schema) {
filteredProjects = filteredProjects.filter( project => project.schema.toLowerCase().includes(schema.toLowerCase()) );
}
if (group) {
filteredProjects = filteredProjects.filter( project => project.groups.find(g => g.toLowerCase() == group.toLowerCase()) );
}
if (text) {
const tstampFilter = (value, search, item) => {
return search?.length >= 5 && textFilter(value, search, item);
};
const numberFilter = (value, search, item) => {
return value == search;
};
const textFilter = (value, search, item) => {
return String(value).toLowerCase().includes(search.toLowerCase());
};
const arrayFilter = (elementFilter) => {
return (value, search, item) => value.some(element => elementFilter(element, search, item));
};
const searchFunctions = {
pid: textFilter,
name: textFilter,
group: arrayFilter(textFilter)
};
filteredProjects = filteredProjects.filter ( project => {
for (let key in searchFunctions) {
const fn = searchFunctions[key];
if (fn(project[key], text, project)) {
return true;
}
}
return false;
});
}
const count = filteredProjects.length;
if (itemsPerPage && itemsPerPage > 0) {
const offset = (page > 0)
? (page-1) * itemsPerPage
: 0;
filteredProjects = filteredProjects.slice(offset, offset+itemsPerPage);
}
return {projects: filteredProjects, count};
}
export default { refreshProjects, getProjects };

View File

@@ -0,0 +1,18 @@
function projects (state) {
return state.projects;
}
function projectGroups (state) {
return [...new Set(state.projects.map(i => i.groups).flat())].sort();
}
function projectCount (state) {
return state.projects?.length ?? 0;
}
function projectsLoading (state) {
return !!state.loading;
}
export default { projects, projectGroups, projectCount, projectsLoading };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,48 @@
function setProjects (state, projects) {
// We don't need or want the array to be reactive
state.projects = Object.freeze(projects);
}
function setProjectsLoading (state, abortController = new AbortController()) {
state.loading = abortController;
}
// This assumes that we know any transactions have finished or we
// don't care about aborting.
function clearProjectsLoading (state) {
state.loading = null;
}
function setProjectsTimestamp (state, timestamp = new Date()) {
// NOTE: There is no `modified_on` property in the projects
// result or in the database schema, but we should probably add
// one.
if (timestamp === true) {
const tstamp = state.projects
.map( event => event.modified_on )
.reduce( (acc, cur) => acc > cur ? acc : cur );
state.timestamp = tstamp ? new Date(tstamp) : new Date();
} else {
state.timestamp = timestamp;
}
}
function setProjectsETag (state, etag) {
state.etag = etag;
}
function abortProjectsLoading (state) {
if (state.loading) {
state.loading.abort();
}
state.loading = null;
}
export default {
setProjects,
setProjectsLoading,
clearProjectsLoading,
setProjectsTimestamp,
setProjectsETag
};

View File

@@ -0,0 +1,8 @@
const state = () => ({
projects: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,
});
export default state;

View File

@@ -37,7 +37,65 @@
</v-btn>
<v-toolbar-title v-if="$refs.calendar">
{{ $refs.calendar.title }}
<span
class="secondary--text"
style="font-size:small;"
>
({{downloadableItemCount}} log entries)
</span>
</v-toolbar-title>
<v-menu class="ml-5" v-if="calendarDates">
<template v-slot:activator="{on, attrs}">
<v-btn
class="ml-5"
small
:title="`Download events for the period ${calendarDates.start} ${calendarDates.end}`"
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="downloadUrl({mime: 'application/vnd.seis+json', filename: `event-log-${calendarDates.start}-${calendarDates.end}-multiseis.json`})"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<!-- Not yet supported
<v-list-item
:href="downloadUrl({mime: 'application/geo+json'})"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
-->
<v-list-item
:href="downloadUrl({mime: 'application/json', filename: `event-log-${calendarDates.start}-${calendarDates.end}.json`})"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'application/yaml', filename: `event-log-${calendarDates.start}-${calendarDates.end}.yaml`})"
title="Download as a YAML file"
>YAML</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'text/csv', sortBy: 'tstamp', sortDesc: false, filename: `event-log-${calendarDates.start}-${calendarDates.end}.csv`})"
title="Download as Comma Separated Values file"
>CSV</v-list-item>
<!-- Not yet supportd
<v-list-item
:href="downloadUrl({mime: 'text/html'})"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="downloadUrl({mime: 'application/pdf'})"
title="Download as a Portable Document File"
>PDF</v-list-item>
-->
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn v-if="categoriesAvailable"
small
@@ -169,6 +227,23 @@ export default {
return [...new Set(this.visibleItems.map(i => i.category ?? "General"))];
},
calendarDates () {
// The this.items.length reference is only needed to force recalculation
// of this computed property, as this.$refs is not reactive.
// https://github.com/vuejs/vue/issues/3842
if (this.items.length && this.$refs.calendar) {
return {
start: this.$refs.calendar.renderProps.start.date,
end: this.$refs.calendar.renderProps.end.date
}
}
},
downloadableItemCount () {
return this.events.filter(i => i.tstamp.substr(0, 10) >= this.calendarDates?.start &&
i.tstamp.substr(0, 10) <= this.calendarDates?.end).length;
},
...mapGetters(['sequencesLoading', 'sequences', 'events'])
},
@@ -298,6 +373,18 @@ export default {
};
},
downloadUrl (qry) {
if (this.calendarDates) {
const url = new URL(`/api/project/${this.$route.params.project}/event`, document.location.href);
for (const key in qry) {
url.searchParams.set(key, qry[key]);
}
url.searchParams.set("date0", this.calendarDates.start);
url.searchParams.set("date1", this.calendarDates.end);
return url.toString();
}
},
...mapActions(["api"])

View File

@@ -33,6 +33,17 @@
</span>
</v-toolbar-title>
<a v-if="$route.params.sequence"
class="mr-3"
:href="`/projects/${$route.params.project}/sequences/${$route.params.sequence}`"
title="View the shotlog for this sequence"
>
<v-icon
right
color="teal"
>mdi-format-list-numbered</v-icon>
</a>
<dougal-event-edit v-if="writeaccess"
v-model="eventDialog"
v-bind="editedEvent"
@@ -86,6 +97,49 @@
>PDF</v-list-item>
</v-list>
</v-menu>
<v-menu v-else>
<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?mime=application%2Fvnd.seis%2Bjson&filename=event-log-multiseis.json`"
title="Download as a Multiseis-compatible Seis+JSON file."
>Seis+JSON</v-list-item>
<!-- Not yet implemented
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fgeo%2Bjson&filename=event-log.geojson`"
title="Download as a QGIS-compatible GeoJSON file"
>GeoJSON</v-list-item>
-->
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fjson&filename=event-log.json`"
title="Download as a generic JSON file"
>JSON</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fyaml&filename=event-log.yaml`"
title="Download as a YAML file"
>YAML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=text%2Fcsv&sortBy=tstamp&sortDesc=false&filename=event-log.csv`"
title="Download as Comma Separated Values file"
>CSV</v-list-item>
<!-- Not yet implemented
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=text%2Fhtml&filename=event-log.html`"
title="Download as an HTML formatted file"
>HTML</v-list-item>
<v-list-item
:href="`/api/project/${$route.params.project}/event?mime=application%2Fpdf&filename=event-log.pdf`"
title="Download as a Portable Document File"
>PDF</v-list-item>
-->
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-text-field
@@ -480,7 +534,7 @@ export default {
}
},
...mapGetters(['user', 'writeaccess', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'serverEvent', 'events', 'labels', 'userLabels']),
...mapGetters(['user', 'writeaccess', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
...mapState({projectSchema: state => state.project.projectSchema})
},
@@ -608,7 +662,6 @@ export default {
this.editedEvent.longitude = template.meta.geometry.coordinates[0];
this.editedEvent.latitude = template.meta.geometry.coordinates[1];
}
},
/** Add a brand new event.

View File

@@ -22,8 +22,6 @@
</template>
</template>
</template>
<template v-slot:item.shots="{item}">
{{ item.total ? (item.prime + item.other) : "" }}
</template>
@@ -112,13 +110,14 @@ export default {
},
computed: {
...mapGetters(['loading', 'serverEvent'])
displayItems () {
return this.showArchived
? this.items
: this.items.filter(i => !i.archived);
},
...mapGetters(['loading', 'serverEvent', 'adminaccess', 'projects'])
},
watch: {

View File

@@ -332,9 +332,31 @@
</template>
<template v-slot:item.sequence="{value}">
<a
:href="`/projects/${$route.params.project}/log/sequence/${value}`"
title="View the event log for this sequence">{{value}}</a>
<div style="white-space:nowrap;">
{{value}}
<a
:href="`/projects/${$route.params.project}/log/sequence/${value}`"
title="View the event log for this sequence"
>
<v-icon
small
right
color="blue"
>mdi-format-list-bulleted-type</v-icon>
</a>
<a
:href="`/projects/${$route.params.project}/sequences/${value}`"
title="View the shotlog for this sequence"
>
<v-icon
small
right
color="teal"
>mdi-format-list-numbered</v-icon>
</a>
</div>
</template>
<template v-slot:item.line="{value}">

View File

@@ -1,13 +1,631 @@
<template>
<div>
SequenceSummary
</div>
<v-container fluid>
<v-card>
<v-card-title>
<v-progress-linear indeterminate v-if="loading"></v-progress-linear>
<v-toolbar flat>
<v-toolbar-title>
Sequence {{sequenceNumber}}
<small :class="statusColour" v-if="sequence">({{sequence.status}})</small>
</v-toolbar-title>
<a v-if="$route.params.sequence"
:href="`/projects/${$route.params.project}/log/sequence/${$route.params.sequence}`"
title="View the event log for this sequence"
>
<v-icon
right
color="blue"
>mdi-format-list-bulleted-type</v-icon>
</a>
<v-spacer></v-spacer>
<v-text-field
v-model="filter"
append-icon="mdi-magnify"
label="Filter shots"
single-line
clearable
hint="Filter by point, line, time, etc."
></v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
<v-container fluid>
<v-row>
<v-col cols="6" class="pa-0">
<v-card outlined tile height="100%" class="flex-grow-1">
<v-card-subtitle>
Acquisition remarks
</v-card-subtitle>
<v-card-text v-html="$options.filters.markdown(remarks)">
</v-card-text>
</v-card>
</v-col>
<v-col cols="6" class="pa-0">
<v-card outlined tile height="100%" class="flex-grow-1">
<v-card-subtitle>
Processing remarks
</v-card-subtitle>
<v-card-text v-html="$options.filters.markdown(remarks_final)">
</v-card-text>
</v-card>
</v-col>
</v-row>
<template v-if="sequence">
<v-row>
<v-col v-for="(col, idx) in infoColumns" :key="idx" cols="3">
<b :title="col.title">{{col.text}}:</b> {{ col.value }}
</v-col>
</v-row>
</template>
<v-row>
<v-col cols=12 md=6 lg=4>
<div style="height:300px;">
<dougal-graph-project-sequence-inline-crossline
:items="shots"
gun-data-format="smsrc"
facet="2dhist"
>
</dougal-graph-project-sequence-inline-crossline>
</div>
</v-col>
<v-col cols=12 md=6 lg=4>
<div style="height:300px;">
<dougal-graph-project-sequence-inline-crossline
:items="shots"
gun-data-format="smsrc"
facet="crossline"
>
</dougal-graph-project-sequence-inline-crossline>
</div>
</v-col>
<v-col cols=12 md=6 lg=4>
<div ref="" style="height:300px;">
<dougal-graph-project-sequence-shotpoint-timing
:items="shots"
facet="area"
>
</dougal-graph-project-sequence-shotpoint-timing>
</div>
</v-col>
<v-col cols=12 md=6 lg=4 >
<div ref="" style="height:300px;">
<dougal-graph-project-sequence-inline-crossline
:items="shots"
facet="c-o"
>
</dougal-graph-project-sequence-inline-crossline>
</div>
</v-col>
</v-row>
</v-container>
<v-card outlined tile class="flex-grow-1">
<v-card-subtitle>
Shot log
</v-card-subtitle>
<v-card-text>
<v-data-table
:headers="shotlogHeaders"
:items="shots"
item-key="point"
:search="filter"
:custom-sort="customSorter"
fixed-header
show-expand
@click:row="onRowClicked"
>
<template v-slot:item.position="{item}">
<a v-if="position(item).latitude"
:href="`/projects/${$route.params.project}/map#15/${position(item).longitude}/${position(item).latitude}`"
title="View on map"
:target="`/projects/${$route.params.project}/map`"
@click.stop
>
<small>{{ format_position(position(item)) }}</small>
</a>
</template>
<template v-slot:item.ε_inline="{item}">
{{ ε(item).inline }}
</template>
<template v-slot:item.ε_crossline="{item}">
{{ ε(item).crossline }}
</template>
<template v-slot:item.co_inline="{item}">
{{ co(item).inline }}
</template>
<template v-slot:item.co_crossline="{item}">
{{ co(item).crossline }}
</template>
<template v-slot:expanded-item="{ headers, item }">
<td :colspan="headers.length" class="pa-0">
<v-card flat>
<v-card-subtitle>Gun data</v-card-subtitle>
<v-card-text v-if="item.meta.raw.smsrc">
<v-container>
<v-row>
<v-col>
Source fired: {{ gun_data(item).src_number }}
</v-col>
<v-col>
Subarrays: {{ gun_data(item).num_subarray }}
</v-col>
<v-col>
Total guns: {{ gun_data(item).num_guns }}
</v-col>
<v-col>
Active guns: {{ gun_data(item).num_active }}
</v-col>
<v-col>
Autofires: {{ gun_data(item).num_auto }}
</v-col>
<v-col>
No fires: {{ gun_data(item).num_nofire }}
</v-col>
</v-row>
<v-row>
<v-col>
Average delta: {{ gun_data(item).avg_delta }} ±{{ gun_data(item).std_delta }} ms
</v-col>
<v-col>
Delta errors: {{ gun_data(item).num_delta }}
</v-col>
<v-col>
Source volume: {{ gun_data(item).volume }} in³
</v-col>
<v-col>
Manifold pressure: {{ gun_data(item).manifold }} psi
</v-col>
<v-col>
Mask: {{ gun_data(item).mask }}
</v-col>
<v-col>
Trigger mode: {{ gun_data(item).trg_mode }}
</v-col>
</v-row>
<v-row>
<v-col cols=12>
<v-expansion-panels>
<v-expansion-panel>
<v-expansion-panel-header>
Individual gun data
</v-expansion-panel-header>
<v-expansion-panel-content>
<v-simple-table>
<template v-slot:default>
<thead>
<th>String</th>
<th>Gun</th>
<th>Source</th>
<th>Mode</th>
<th>Detect</th>
<th>Autofire</th>
<th>Aimpoint</th>
<th>Fire time</th>
<th>Delay</th>
<th>Depth</th>
<th>Pressure</th>
<th>Volume</th>
<th>Filltime</th>
</thead>
<tbody>
<tr v-for="(gun_values, idx_gun) in gun_data(item).guns"
:key="idx_gun"
>
<td v-for="(val, idx) in gun_values" v-if="idx != 6"
:key="idx"
:class="typeof val === 'number' ? 'text-right' : 'text-left'"
>{{ gun_value_formatter(val, idx) }}</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-text v-else>
No gun data available {{ item }}
</v-card-text>
</v-card>
</td>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalGraphProjectSequenceInlineCrossline from '@/components/graphs/project/sequence/inline-crossline';
import DougalGraphProjectSequenceShotpointTiming from '@/components/graphs/project/sequence/shotpoint-timing';
export default {
name: "SequenceSummary"
name: "SequenceSummary",
components: {
DougalGraphProjectSequenceInlineCrossline,
DougalGraphProjectSequenceShotpointTiming
},
data () {
return {
filter: null,
loading: false,
shotlogHeaders: [
{
value: "point",
text: "Point",
sort: this.sorterFor("point", this.number_sort)
},
{
value: "sailline",
text: "Sail line",
sort: this.sorterFor("sailline", this.number_sort)
},
{
value: "line",
text: "Source line",
sort: this.sorterFor("line", this.number_sort)
},
{
value: "tstamp",
text: "Timestamp",
sort: this.sorterFor("tstamp", this.string_sort)
},
{
value: "position",
text: "φ, λ",
sort: this.sorterFor(["geometryfinal", "geometryraw", "geometrypreplot"],
this.sorterFor("coordinates",
this.sequentialSort([0, 1], this.number_sort)))
},
{
value: "ε_inline",
text: "ε inline",
align: "end",
sort: this.sorterFor(["errorfinal", "errorraw"],
this.sorterFor("coordinates",
this.sorterFor(1, this.number_sort)))
},
{
value: "ε_crossline",
text: "ε crossline",
align: "end",
sort: this.sorterFor(["errorfinal", "errorraw"],
this.sorterFor("coordinates",
this.sorterFor(0, this.number_sort)))
},
{
value: "co_inline",
text: "c-o inline",
align: "end",
sort: (a, b) => this.number_sort(this.co(a).inline, this.co(b).inline)
},
{
value: "co_crossline",
text: "c-o crossline",
align: "end",
sort: (a, b) => this.number_sort(this.co(a).crossline, this.co(b).crossline)
},
{
value: 'data-table-expand'
},
],
shots: []
};
},
computed: {
sequenceNumber () {
return this.$route.params.sequence;
},
sequence () {
return this.sequences.find(i => i.sequence == this.sequenceNumber);
},
remarks () {
return this.sequence?.remarks || "Nil.";
},
remarks_final () {
return this.sequence?.remarks_final || "Nil.";
},
statusColour () {
switch (this.sequence?.status) {
case "final":
return "green--text";
case "raw":
return "orange--text";
case "ntbp":
return "red--text";
default:
return "";
}
},
infoColumns () {
return [
{
text: "FSP",
title: "First shotpoint",
value: this.sequence.fsp ?? "n/a"
},
{
text: "FPSP",
title: "First prime shotpoint",
value: this.sequence.fsp_final ?? "n/a"
},
{
text: "LPSP",
title: "Last prime shotpoint",
value: this.sequence.lsp_final ?? "n/a"
},
{
text: "LSP",
title: "Last shotpoint",
value: this.sequence.lsp ?? "n/a"
},
{
text: "Start time",
value: this.sequence.ts0,
},
{
text: "FPSP time",
title: "First prime shotpoint time",
value: this.sequence.ts0_final ?? "n/a",
},
{
text: "LPSP time",
title: "Last prime shotpoint time",
value: this.sequence.ts1_final ?? "n/a",
},
{
text: "End time",
value: this.sequence.ts1,
},
{
text: "Length",
value: this.sequence.length.toFixed(0)+" m"
},
{
text: "Azimuth",
value: this.sequence.azimuth.toFixed(4).padStart(8, "0")+"°"
},
{
text: "Shots acquired",
value: this.sequence.num_points
},
{
text: "Shots missed",
value: this.sequence.missing_shots
},
{
text: "Total duration",
value: this.format_duration(this.sequence.duration)
},
{
text: "Prime duration",
value: this.format_duration(this.sequence.duration_final) ?? "n/a"
}
];
},
...mapGetters(["sequencesLoading", "sequences"])
},
watch: {
sequencesLoading () {
this.loading == this.loading || this.sequencesLoading;
}
},
methods: {
/** Apply fn to a[keys], b[keys]
*
* If keys is an array, use the first
* attribute that exists in each of a
* and b (may not be the same attribute)
*/
sorterFor (keys, fn) {
if (Array.isArray(keys)) {
return (a, b) => fn(a[keys.find(k => k in a)], b[keys.find(k => k in b)]);
} else {
return (a, b) => fn(a[keys], b[keys]);
}
},
/** Apply fn to each member of keys
* until the comparison returns non-zero.
*/
sequentialSort (keys, fn) {
return (a, b) => {
for (const key of keys) {
const res = fn(a[key], b[key]);
if (res != 0) {
return res;
}
}
return 0;
}
},
number_sort (a, b) {
return a - b;
},
string_sort (a, b) {
return a > b
? 1
: a < b
? -1
: 0;
},
customSorter (items, sortBy, sortDesc) {
/** Get the sorter for a given column from shotlogHeaders
*/
const getFieldSorter = (key) => {
return this.shotlogHeaders.find(i => i.value == key)?.sort;
}
/** Conditionally invert a comparator
*/
function inverter (fn, desc) {
return desc
? (a, b) => fn(a, b) * -1
: fn;
}
for (const idx in sortBy) {
const sortKey = sortBy[idx];
const desc = sortDesc[idx];
const fn = inverter(getFieldSorter(sortKey), desc);
items.sort(fn);
}
return items;
},
onRowClicked (data, row) {
row.expand(!row.isExpanded);
},
async getSequence (seq = this.sequence) {
// NOTE:
// We are intentionally not using Vuex here, given that at this time
// no other component (except Graphs?) this endpoint's data.
// NB: Graphs does use this endpoint but there doesn't seem to be
// any point in keeping the data in memory anyway in case the user
// decides to navigate from the shotlog to the graphs or vice-versa.
try {
this.loading = true;
const url = `/project/${this.$route.params.project}/sequence/${this.$route.params.sequence}`;
this.shots = await this.api([url])
} catch {
} finally {
this.loading = false;
}
},
format_duration (duration) {
if (duration) {
let t = ["hours", "minutes", "seconds"].map(k =>
(duration[k] ?? 0).toFixed(0).padStart(2, "0")).join(":");
if (duration.days) {
t = duration.days+" d "+t;
}
return t;
}
},
format_position (position) {
return `${position.latitude.toFixed(6).padStart(9, "0")}, ${position.longitude.toFixed(6).padStart(10, "0")}`;
},
position (item) {
const coords = (item.geometryfinal ?? item.geometryraw)?.coordinates;
if (coords) {
return {
longitude: coords[0],
latitude: coords[1]
};
}
return {};
},
ε (item) {
const coords = (item.errorfinal ?? item.errorraw)?.coordinates;
if (coords) {
return {
crossline: coords[0]?.toFixed(2),
inline: coords[1]?.toFixed(2)
}
}
return {};
},
co (item) {
const raw = item.errorraw?.coordinates;
const final = item.errorfinal?.coordinates;
if (raw && final) {
return {
crossline: (final[0] - raw[0]).toFixed(2),
inline: (final[1] - raw[1]).toFixed(2),
}
} else {
return {
crossline: "n/a",
inline: "n/a"
}
}
},
gun_data (item) {
return item?.meta?.raw?.smsrc ?? {};
},
gun_value_formatter (value, index) {
const ms = (v) => v.toFixed(2)+" ms";
const transformers = [];
transformers[7] = ms;
transformers[8] = ms;
transformers[9] = ms;
transformers[10] = (v) => v.toFixed(1)+" m";
transformers[11] = (v) => v+" psi";
transformers[12] = (v) => v+" in³";
transformers[13] = ms;
const transformer = transformers[index];
return transformer ? transformer(value) : value;
},
...mapActions(["api"])
},
mounted () {
this.getSequence();
}
}
</script>

View File

@@ -7,10 +7,10 @@ module.exports = {
host: "0.0.0.0",
proxy: {
"^/api(/|$)": {
target: "http://localhost:3000",
target: "http://127.0.0.1:3000",
},
"^/ws(/|$)": {
target: "ws://localhost:3000",
target: "ws://127.0.0.1:3000",
ws: true
}
}

View File

@@ -3,6 +3,7 @@ const yaml = require('./yaml');
const geojson = require('./geojson');
const seis = require('./seis');
const csv = require('./csv');
const setContentDisposition = require('../../../../lib/utils/setContentDisposition');
module.exports = async function (req, res, next) {
@@ -19,6 +20,7 @@ module.exports = async function (req, res, next) {
if (mimetype) {
res.set("Content-Type", mimetype);
setContentDisposition(req, res);
await handlers[mimetype](req, res, next);
} else {
res.status(406).send();

View File

@@ -8,5 +8,6 @@ module.exports = {
removeNulls: require('./removeNulls'),
logicalPath: require('./logicalPath'),
ranges: require('./ranges'),
unique: require('./unique')
unique: require('./unique'),
setContentDisposition: require('./setContentDisposition')
};

View File

@@ -0,0 +1,8 @@
function setContentDisposition (req, res) {
if (req.query.filename) {
res.set("Content-Disposition", `attachment; filename="${req.query.filename}"`);
}
}
module.exports = setContentDisposition;

View File

@@ -216,6 +216,26 @@ components:
pattern: "(([^\\s,;:]+)(\\s*[,;:\\s]\\s*)?)+"
example: "line,point,tstamp"
QueryMime:
name: mime
description: |
Ask for the representation of the requested resource to be provided in the indicated MIME type. Overrides the [`Accept`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) request header values (if any).
Note that the server is not obliged to honour the requested type and a default (usually `application/json`) might be provided instead. Always check the response's [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header.
in: query
schema:
type: string
QueryFilename:
name: filename
description: |
Cause the [`Content-Disposition`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) response header to be set and suggest a file name to download as.
Note that the [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) response header is not affected by this parameter.
in: query
schema:
type: string
CSVDelimiter:
description: Delimiter value to use in CSV requests or responses.
name: delimiter
@@ -1394,6 +1414,8 @@ paths:
- $ref: "#/components/parameters/CSVDelimiter"
- $ref: "#/components/parameters/CSVFields"
- $ref: "#/components/parameters/CSVHeader"
- $ref: "#/components/parameters/QueryMime"
- $ref: "#/components/parameters/QueryFilename"
responses:
"200":
description: List of project events