Compare commits

..

46 Commits

Author SHA1 Message Date
D. Berge
f968cf3b3c Bump the required database schema version 2023-11-02 13:25:34 +01:00
D. Berge
b148ed2368 Add refresh-project-summary periodic task.
It listens for events that might indicate that the project_summary
materialised view needs to be refreshed and schedules a refresh.

Refreshes are throttled to a maximum of one every throttlePeriod
milliseconds so that things don't get too crazy for instance when
importing a lot of data.
2023-11-02 13:25:34 +01:00
D. Berge
cb35e340e1 Change the periodic tasks interface to support an init() function.
When a task needs to keep state, it can do so via a closure.
2023-11-02 13:25:34 +01:00
D. Berge
6c00f16b7e Add a refresh() method to db.project.summary 2023-11-02 13:25:34 +01:00
D. Berge
ca8dd68d10 Add database upgrade file 32. 2023-11-02 13:25:34 +01:00
D. Berge
656f776262 Do not cache any responses containing cookies 2023-11-02 13:24:40 +01:00
D. Berge
e1b40547f1 Deconflict Webpack's dev-server websocket.
It uses /ws which is the same path Dougal uses.

Changing Dougal's path to something more imaginative requires
reconfiguring Nginx though.
2023-11-02 13:20:41 +01:00
D. Berge
98021441bc Revert "Rename websocket /ws → /dougal-websocket."
This reverts commit 4a8d3a99c1.
2023-11-02 13:18:37 +01:00
D. Berge
4a8d3a99c1 Rename websocket /ws → /dougal-websocket.
The Webpack dev server seems to really like /ws and ignores any
attempts to set a different path, so we just rename our websocket
instead.
2023-11-02 11:39:49 +01:00
D. Berge
7dee457fa1 Add FIXME comment 2023-11-02 11:38:27 +01:00
D. Berge
bccac446e5 Add polyfill for node:path so we can get basename() 2023-11-01 15:12:30 +01:00
D. Berge
535b3bcc12 Adapt to new Marked interface 2023-11-01 14:59:10 +01:00
D. Berge
11e84a7e72 Upgrade Node packages for frontend build 2023-10-31 22:41:37 +01:00
D. Berge
5ef55a9d8e Add dark mode support for QC graphs.
Closes #159.
2023-10-31 21:33:30 +01:00
D. Berge
f53e15df93 Merge branch '265-add-shotlog' into 'devel'
Resolve "Add shotlog"

Closes #265

See merge request wgp/dougal/software!54
2023-10-31 18:51:16 +00:00
D. Berge
cf887b7852 Upgrade Plotly 2023-10-31 19:49:40 +01:00
D. Berge
a917976a3a Fix styling of the download button in the event log 2023-10-31 19:48:42 +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
42 changed files with 9953 additions and 15291 deletions

View File

@@ -0,0 +1,147 @@
-- Turn project_summary into a materialised view
--
-- New schema version: 0.4.5
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects all schemas in the database.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- The project_summary view is quite a bottleneck. While it itself is
-- not the real culprit (rather the underlying views are), this is one
-- relatively cheap way of improving responsiveness from the client's
-- point of view.
-- We leave the details of how / when to refresh the view to the non-
-- database code.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_survey_schema (schema_name text) AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', schema_name;
-- We need to set the search path because some of the trigger
-- functions reference other tables in survey schemas assuming
-- they are in the search path.
EXECUTE format('SET search_path TO %I,public', schema_name);
DROP VIEW project_summary;
CREATE MATERIALIZED VIEW project_summary AS
WITH fls AS (
SELECT
avg((final_lines_summary.duration / ((final_lines_summary.num_points - 1))::double precision)) AS shooting_rate,
avg((final_lines_summary.length / date_part('epoch'::text, final_lines_summary.duration))) AS speed,
sum(final_lines_summary.duration) AS prod_duration,
sum(final_lines_summary.length) AS prod_distance
FROM final_lines_summary
), project AS (
SELECT
p.pid,
p.name,
p.schema
FROM public.projects p
WHERE (split_part(current_setting('search_path'::text), ','::text, 1) = p.schema)
)
SELECT
project.pid,
project.name,
project.schema,
( SELECT count(*) AS count
FROM preplot_lines
WHERE (preplot_lines.class = 'V'::bpchar)) AS lines,
ps.total,
ps.virgin,
ps.prime,
ps.other,
ps.ntba,
ps.remaining,
( SELECT to_json(fs.*) AS to_json
FROM final_shots fs
ORDER BY fs.tstamp
LIMIT 1) AS fsp,
( SELECT to_json(fs.*) AS to_json
FROM final_shots fs
ORDER BY fs.tstamp DESC
LIMIT 1) AS lsp,
( SELECT count(*) AS count
FROM raw_lines rl) AS seq_raw,
( SELECT count(*) AS count
FROM final_lines rl) AS seq_final,
fls.prod_duration,
fls.prod_distance,
fls.speed AS shooting_rate
FROM preplot_summary ps,
fls,
project;
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.4.5' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.4.4' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
FOR row IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'survey_%'
ORDER BY schema_name
LOOP
CALL pg_temp.upgrade_survey_schema(row.schema_name);
END LOOP;
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_survey_schema (schema_name text);
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.4.5"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.4.5"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,9 @@
"leaflet-arrowheads": "^1.2.2",
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"marked": "^2.0.3",
"plotly.js-dist": "^2.5.0",
"marked": "^9.1.4",
"path-browserify": "^1.0.1",
"plotly.js-dist": "^2.27.0",
"suncalc": "^1.8.0",
"typeface-roboto": "0.0.75",
"vue": "^2.6.12",
@@ -27,10 +28,10 @@
},
"devDependencies": {
"@babel/plugin-proposal-logical-assignment-operators": "^7.14.5",
"@vue/cli-plugin-babel": "~4.4.0",
"@vue/cli-plugin-router": "~4.4.0",
"@vue/cli-plugin-vuex": "~4.4.0",
"@vue/cli-service": "^4.5.13",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-plugin-vuex": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"sass": "~1.32",
"sass-loader": "^8.0.0",
"stylus": "^0.54.8",

View File

@@ -123,29 +123,11 @@
<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>
<dougal-event-select
v-bind.sync="entryRemarks"
:preset-remarks="presetRemarks"
@update:labels="(v) => this.entryLabels = v"
></dougal-event-select>
</v-col>
</v-row>
@@ -290,6 +272,7 @@
<script>
import { mapActions } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventSelect from '@/components/event-select';
function stringSort (a, b) {
return a == b
@@ -308,6 +291,7 @@ function flattenRemarks(items, keywords=[], labels=[]) {
if (!item.items) {
result.push({
text: item.text,
properties: item.properties,
labels: labels.concat(item.labels??[]),
keywords
})
@@ -342,7 +326,8 @@ export default {
name: 'DougalEventEdit',
components: {
DougalContextMenu
DougalContextMenu,
DougalEventSelect
},
props: {
@@ -354,6 +339,7 @@ export default {
sequence: { type: Number },
point: { type: Number },
remarks: { type: String },
meta: { type: Object },
labels: { type: Array, default: () => [] },
latitude: { type: Number },
longitude: { type: Number },
@@ -371,18 +357,11 @@ export default {
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
@@ -394,11 +373,6 @@ export default {
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;
@@ -502,11 +476,8 @@ export default {
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());
this.makeEntryRemarks();
}
},
@@ -577,22 +548,13 @@ export default {
};
},
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;
makeEntryRemarks () {
this.entryRemarks = {
template: null,
schema: {},
values: [],
...this.meta?.structured_values,
text: this.remarks
}
},
@@ -657,14 +619,24 @@ export default {
save () {
// In case the focus goes directly from the remarks field
// to the Save button.
if (this.entryRemarksInput != this.entryRemarksText) {
this.entryRemarks = this.entryRemarksInput;
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
labels: this.entryLabels,
meta
};
/* NOTE This is the purist way.

View File

@@ -0,0 +1,142 @@
<template>
<v-card flat>
<v-card-subtitle v-text="text">
</v-card-subtitle>
<v-card-text style="max-height:350px;overflow:scroll;">
<v-form>
<template v-for="key in fieldKeys">
<template v-if="schema[key].enum">
<v-select v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
></v-select>
<v-select v-else :key="key"
v-model="fieldValues[key]"
:items="schema[key].enum"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
></v-select>
</template>
<template v-else>
<v-text-field v-if="schema[key].type == 'number'" :key="key"
v-model.number="fieldValues[key]"
type="number"
:min="schema[key].minimum"
:max="schema[key].maximum"
:step="schema[key].multiplier"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, Number($event))"
>
</v-text-field>
<v-text-field v-else-if="schema[key].type == 'string'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
<v-checkbox v-else-if="schema[key].type == 'boolean'" :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@change="updateFieldValue(key, $event)"
>
</v-checkbox>
<v-text-field v-else :key="key"
v-model="fieldValues[key]"
:label="schema[key].title"
:hint="schema[key].description"
@input="updateFieldValue(key, $event)"
>
</v-text-field>
</template>
</template>
</v-form>
</v-card-text>
</v-card>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: "DougalEventPropertiesEdit",
components: {
},
props: {
value: String,
template: String,
schema: Object,
values: Array
},
data () {
return {
}
},
computed: {
fieldKeys () {
return Object.entries(this.schema).sort((a, b) => a[1].title > b[1].title ? 1 : -1).map(i => i[0]);
},
fieldValues () {
const keys = Object.keys(this.schema ?? this.values);
return Object.fromEntries(
keys.map( (k, idx) =>
[ k, this.values?.[idx] ?? this.schema[k].default ]));
},
/*
fields () {
// TODO Remove this and rename fields → schema
return this.schema;
},
*/
text () {
if (this.template) {
const rx = /{{([a-z_][a-z0-9_]*)}}/ig;
return this.template.replace(rx, (match, p1) => this.fieldValues[p1] ?? "(n/a)");
}
}
},
watch: {
values () {
this.$emit("input", this.text);
},
template () {
this.$emit("input", this.text);
},
schema () {
this.$emit("input", this.text);
}
},
methods: {
updateFieldValue(key, ev) {
const values = {...this.fieldValues};
values[key] = ev;
this.$emit("update:values", Object.values(values));
}
},
mount () {
}
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div>
<v-combobox
ref="remarks"
:value="text"
@input="handleComboBox"
: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-expansion-panels v-if="haveProperties"
class="px-8"
:value="0"
>
<v-expansion-panel>
<v-expansion-panel-header>Properties</v-expansion-panel-header>
<v-expansion-panel-content>
<dougal-event-properties-edit
:value="text"
@input="$emit('update:text', $event)"
:template="template"
:schema="schema"
:values="values"
@update:values="$emit('update:values', $event)"
>
</dougal-event-properties-edit>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalContextMenu from '@/components/context-menu';
import DougalEventPropertiesEdit from '@/components/event-properties';
export default {
name: "DougalEventSelect",
components: {
DougalContextMenu,
DougalEventPropertiesEdit
},
props: {
text: String,
template: String,
schema: Object,
values: Array,
presetRemarks: Array
},
data () {
return {
entryRemarksInput: null,
remarksMenu: false,
}
},
computed: {
remarksAvailable () {
return this.entryRemarksInput == this.text
? []
: this.flattenRemarks(this.presetRemarks);
},
haveProperties () {
for (const key in this.schema) {
return true;
}
return false;
}
},
watch: {
},
methods: {
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(...this.flattenRemarks(item.items, k, l))
}
}
}
return result;
},
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));
},
handleComboBox (event) {
if (typeof event == "object") {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
} else {
this.$emit("update:text", event);
this.$emit("update:template", null);
this.$emit("update:properties", null);
this.$emit("update:labels", []);
}
},
handleRemarksMenu (event) {
if (typeof event == 'boolean') {
this.remarksMenu = event;
} else {
this.$emit("update:text", event.text);
this.$emit("update:template", event.template ?? event.text);
this.$emit("update:schema", event.properties);
this.$emit("update:labels", event.labels);
this.remarksMenu = false;
}
},
},
mount () {
// Focus remarks field
this.$nextTick(() => this.$refs.remarks.focus());
}
}
</script>

View File

@@ -29,7 +29,7 @@
<style>
@font-face {
font-family: "Bank Gothic Medium";
src: local("Bank Gothic Medium"), url("/fonts/bank-gothic-medium.woff");
src: local("Bank Gothic Medium"), url("/public/fonts/bank-gothic-medium.woff");
}
.brand {

View File

@@ -1,5 +1,5 @@
<template>
<v-card style="min-height:400px;">
<v-card style="min-height:400px;" outlined>
<v-card-title class="headline">
Array inline / crossline error
<v-spacer></v-spacer>
@@ -35,7 +35,6 @@
<style scoped>
.graph-container {
background-color: red;
width: 100%;
height: 100%;
}
@@ -95,6 +94,10 @@ export default {
scatterplot () {
this.plot();
this.$emit("update:settings", {[`${this.$options.name}.scatterplot`]: this.scatterplot});
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
@@ -175,6 +178,11 @@ export default {
title: "Shotpoint",
anchor: "x1"
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};
@@ -233,6 +241,11 @@ export default {
xaxis: {
title: "Crossline (m)"
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};
@@ -306,6 +319,11 @@ export default {
domain: [ 0.55, 1 ],
anchor: 'x2'
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};

View File

@@ -1,5 +1,5 @@
<template>
<v-card style="min-height:400px;">
<v-card style="min-height:400px;" outlined>
<v-card-title class="headline">
Gun depth
<v-spacer></v-spacer>
@@ -98,6 +98,10 @@ export default {
this.plotViolin();
}
this.$emit("update:settings", {[`${this.$options.name}.violinplot`]: this.violinplot});
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
@@ -196,6 +200,11 @@ export default {
title: "Shotpoint",
showspikes: true
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};
@@ -232,6 +241,11 @@ export default {
title: "Gun number",
type: 'category'
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: {
point
}
@@ -305,6 +319,11 @@ export default {
xaxis: {
title: "Gun number"
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};

View File

@@ -1,5 +1,5 @@
<template>
<v-card style="min-height:400px;">
<v-card style="min-height:400px;" outlined>
<v-card-title class="headline">
Gun details
</v-card-title>
@@ -76,6 +76,10 @@ export default {
if (this.violinplot) {
this.plotViolin();
}
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
@@ -332,6 +336,11 @@ export default {
title: "Shotpoint",
showspikes: true
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};

View File

@@ -1,5 +1,5 @@
<template>
<v-card style="min-height:400px;">
<v-card style="min-height:400px;" outlined>
<v-card-title class="headline">
Gun pressures
<v-spacer></v-spacer>
@@ -98,6 +98,10 @@ export default {
this.plotViolin();
}
this.$emit("update:settings", {[`${this.$options.name}.violinplot`]: this.violinplot});
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
@@ -123,7 +127,7 @@ export default {
const gunPressuresSorted = gunPressures.map(s => d3a.sort(s));
const gunVolumes = guns.map(s => s.map(g => g[12]));
const gunPressureWeights = gunVolumes.map( (s, sidx) => s.map( v => v/meta[sidx].volume ));
const gunsWeightedAvgPressure = gunPressures.map( (s, sidx) =>
const gunsWeightedAvgPressure = gunPressures.map( (s, sidx) =>
d3a.sum(s.map( (pressure, gidx) => pressure * gunPressureWeights[sidx][gidx] )) / d3a.sum(gunPressureWeights[sidx])
);
@@ -210,6 +214,11 @@ export default {
title: "Shotpoint",
showspikes: true
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};
@@ -249,6 +258,11 @@ export default {
title: "Gun number",
type: 'category'
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: {
point
}
@@ -322,6 +336,11 @@ export default {
xaxis: {
title: "Gun number"
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};

View File

@@ -1,5 +1,5 @@
<template>
<v-card style="min-height:400px;">
<v-card style="min-height:400px;" outlined>
<v-card-title class="headline">
Gun timing
<v-spacer></v-spacer>
@@ -98,6 +98,10 @@ export default {
this.plotViolin();
}
this.$emit("update:settings", {[`${this.$options.name}.violinplot`]: this.violinplot});
},
"$vuetify.theme.isDark" () {
this.plot();
}
},
@@ -196,6 +200,11 @@ export default {
title: "Shotpoint",
showspikes: true
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};
@@ -232,6 +241,11 @@ export default {
title: "Gun number",
type: 'category'
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: {
point
}
@@ -305,6 +319,11 @@ export default {
xaxis: {
title: "Gun number"
},
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)",
meta: this.data.meta
};

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,11 +1,11 @@
const marked = require('marked');
const { marked, parseInline } = require('marked');
function markdown (str) {
return marked(String(str));
}
function markdownInline (str) {
return marked.parseInline(String(str));
return parseInline(String(str));
}
module.exports = { markdown, markdownInline };

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"
@@ -53,9 +64,13 @@
<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 class="ml-3" small v-on="on" v-bind="attrs">
<span class="d-none d-lg-inline">Download as
<v-icon right small>mdi-cloud-download</v-icon>
</span>
<span class="d-lg-none">
<v-icon small>mdi-cloud-download</v-icon>
</span>
</v-btn>
</template>
@@ -86,6 +101,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 +538,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 +666,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

@@ -1,14 +1,25 @@
<template>
<v-container fluid>
<v-data-table
:headers="headers"
:items="items"
:items="displayItems"
:options.sync="options"
:loading="loading"
>
<template v-slot:item.pid="{item, value}">
<a :href="`/projects/${item.pid}`">{{value}}</a>
<template v-if="item.archived">
<a class="secondary--text" title="This project has been archived" :href="`/projects/${item.pid}`">{{value}}</a>
<v-icon
class="ml-1 secondary--text"
small
title="This project has been archived"
>mdi-archive-outline</v-icon>
</template>
<template v-else>
<a :href="`/projects/${item.pid}`">{{value}}</a>
</template>
</template>
<template v-slot:item.shots="{item}">
@@ -27,6 +38,19 @@
/>
</template>
<template v-slot:footer.prepend>
<v-checkbox
class="mr-3"
v-model="showArchived"
dense
hide-details
title="Projects that have been marked as archived by an administrator no longer receive updates from external sources, such as the project's file repository or the navigation system, but they may still be interacted with via Dougal, including adding or editing log comments."
>
<template v-slot:label>
<span class="subtitle-2">Show archived projects</span>
</template>
</v-checkbox>
</template>
</v-data-table>
@@ -79,11 +103,21 @@ export default {
],
items: [],
options: {},
showArchived: true,
}
},
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

@@ -5,12 +5,24 @@ module.exports = {
],
devServer: {
host: "0.0.0.0",
webSocketServer: {
// Not found in the Webpack docs. Reference:
// https://github.com/webpack/webpack-dev-server/blob/540c43852ea33f9cb18820e1cef05d5ddb86cc3e/lib/servers/WebsocketServer.js#L21
options: {
// Not found in the ws docs. Reference:
// https://github.com/websockets/ws/blob/d8dd4852b81982fc0a6d633673968dff90985000/lib/websocket-server.js#L68
path: "/webpack-dev-server"
}
},
client: {
webSocketURL: "auto://0.0.0.0:0/webpack-dev-server"
},
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
}
}
@@ -22,5 +34,20 @@ module.exports = {
args[0].title = 'Dougal Web'
return args
})
},
configureWebpack: {
resolve: {
fallback: {
path: require.resolve("path-browserify")
}
},
module: {
rules: [
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource'
},
],
},
}
}

View File

@@ -30,11 +30,14 @@ function saveResponse (res) {
if (res?.headersSent) {
const etag = res.get("ETag");
if (etag && res.locals.saveEtag !== false) {
if (res.get("set-cookie")) {
// Do not save any responses containing cookies
return;
}
const cache = getCache(res);
const req = res.req;
console.log(`Saving ETag: ${req.method} ${req.url}${etag}`);
const headers = structuredClone(res.getHeaders());
delete headers["set-cookie"];
cache[req.url] = {etag, headers};
}
}

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

@@ -9,6 +9,7 @@ async function list (projectId, opts = {}) {
const offset = Math.abs((opts.page-1)*opts.itemsPerPage) || 0;
const limit = Math.abs(Number(opts.itemsPerPage)) || null;
// FIXME Move this into the database as a view or something
const text = `
WITH counts AS (
SELECT pls.*, COALESCE(ppc.virgin, 0) na, COALESCE(ppc.tba, 0) tba

View File

@@ -1,7 +1,16 @@
const { setSurvey, pool } = require('../connection');
async function get () {
const res = await pool.query("SELECT pid, name, schema, COALESCE(meta->'groups', '[]'::jsonb) AS groups FROM public.projects;");
const text = `
SELECT
pid,
name,
schema,
COALESCE(meta->'groups', '[]'::jsonb) AS groups,
COALESCE(meta->'archived', 'false'::jsonb) AS archived
FROM public.projects;
`;
const res = await pool.query(text);
return res.rows;
}

View File

@@ -1,3 +1,4 @@
module.exports = {
get: require('./get'),
refresh: require('./refresh')
};

View File

@@ -0,0 +1,23 @@
const { setSurvey } = require('../../connection');
async function refresh (projectId, opts = {}) {
try {
const client = await setSurvey(projectId);
const text = `
REFRESH MATERIALIZED VIEW project_summary;
`;
const res = await client.query(text);
client.release();
return res.rows[0];
} catch (err) {
if (err.code == "42P01") {
throw { status: 404, message: "Not found" };
} else {
throw err;
}
}
}
module.exports = refresh;

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

@@ -11,7 +11,7 @@
"license": "UNLICENSED",
"private": true,
"config": {
"db_schema": "^0.4.2",
"db_schema": "^0.4.5",
"api": "^0.4.0"
},
"engines": {

View File

@@ -4,10 +4,11 @@ const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debu
function init () {
const iids = [];
function start () {
async function start () {
INFO("Initialising %d periodic tasks", tasks.length);
for (let t of tasks) {
const iid = setInterval(t.task, t.timeout);
const fn = t.init ? await t.init() : t.task;
const iid = setInterval(fn, t.timeout);
iids.push(iid);
}
return iids;

View File

@@ -1,4 +1,5 @@
module.exports = [
require('./purge-notifications')
require('./purge-notifications'),
require('./refresh-project-summary')
];

View File

@@ -0,0 +1,85 @@
const db = require('../../lib/db');
const { listen } = require('../../lib/db/notify');
const { ALERT, ERROR, WARNING, NOTICE, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
const timeout = 30*1000;
async function init () {
INFO("Setting up task");
// Full list of channels in
// ../lib/db/channels
const channels = [
"project",
"preplot_lines", "preplot_points",
"raw_lines", "raw_shots",
"final_lines", "final_shots"
];
const throttlePeriod = 10*1000;
const projects = {};
listen (channels, (data) => {
// Something important has changed,
// set the dirty flag for the relevant project
const pid = data.payload?.pid ?? data.payload?.new?.pid ?? data.payload?.old?.pid;
if (pid) {
if (!pid in projects) {
projects[pid] = {
lastRefreshed: 0
};
}
if (!projects[pid].needsRefresh) {
projects[pid].needsRefresh = true;
DEBUG("Setting up refresh flag for %s: %j", pid, projects[pid]);
}
}
});
const task = async () => {
for (pid in projects) {
const project = projects[pid];
if (project.needsRefresh) {
const now = Date.now();
const lastRefreshAge = now - project.lastRefreshed;
if (lastRefreshAge > throttlePeriod) {
// Do the actual refresh
try {
DEBUG("Refreshing", pid);
await db.project.summary.refresh(pid);
} catch (err) {
if (err.status == 404) {
DEBUG("Project %s not found. Removing from refresh list", pid);
delete projects[pid];
} else {
ERROR(err);
}
}
project.needsRefresh = false;
project.lastRefreshed = now;
}
}
}
};
// Let us populate the project list and do a first refresh on startup
for (const project of await db.project.get()) {
projects[project.pid] = {
lastRefreshed: 0,
needsRefresh: true
}
}
task(); // No need to await
return task;
}
async function cleanup () {
}
module.exports = {
init,
timeout,
cleanup
};

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