mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 12:17:08 +00:00
Compare commits
46 Commits
285-refact
...
287-the-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f968cf3b3c | ||
|
|
b148ed2368 | ||
|
|
cb35e340e1 | ||
|
|
6c00f16b7e | ||
|
|
ca8dd68d10 | ||
|
|
656f776262 | ||
|
|
e1b40547f1 | ||
|
|
98021441bc | ||
|
|
4a8d3a99c1 | ||
|
|
7dee457fa1 | ||
|
|
bccac446e5 | ||
|
|
535b3bcc12 | ||
|
|
11e84a7e72 | ||
|
|
5ef55a9d8e | ||
|
|
f53e15df93 | ||
|
|
cf887b7852 | ||
|
|
a917976a3a | ||
|
|
c201229891 | ||
|
|
7ac997cd7d | ||
|
|
08e6c4a2de | ||
|
|
2c21f8f7ef | ||
|
|
a76aefe418 | ||
|
|
8d825fc53b | ||
|
|
b039a5f1fd | ||
|
|
5c1218e95e | ||
|
|
1bb5e2a41d | ||
|
|
1576b121e6 | ||
|
|
a06cdde449 | ||
|
|
121131e910 | ||
|
|
9136e9655d | ||
|
|
c646944886 | ||
|
|
0e664fc095 | ||
|
|
1498891004 | ||
|
|
89cb237f8d | ||
|
|
3386c57670 | ||
|
|
7285de5ec4 | ||
|
|
a95059f5e5 | ||
|
|
1ac81c34ce | ||
|
|
22387ba215 | ||
|
|
b77d41e952 | ||
|
|
aeecb7db7d | ||
|
|
ac9a683135 | ||
|
|
17a58f1396 | ||
|
|
b2a97a1987 | ||
|
|
f684e3e8d6 | ||
|
|
219425245f |
@@ -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
|
||||
--
|
||||
22813
lib/www/client/source/package-lock.json
generated
22813
lib/www/client/source/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
142
lib/www/client/source/src/components/event-properties.vue
Normal file
142
lib/www/client/source/src/components/event-properties.vue
Normal 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>
|
||||
163
lib/www/client/source/src/components/event-select.vue
Normal file
163
lib/www/client/source/src/components/event-select.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -3,6 +3,7 @@
|
||||
<v-app-bar
|
||||
app
|
||||
clipped-left
|
||||
elevation="1"
|
||||
>
|
||||
<v-img src="/wgp-logo.png"
|
||||
contain
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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] );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
120
lib/www/client/source/src/store/modules/projects/actions.js
Normal file
120
lib/www/client/source/src/store/modules/projects/actions.js
Normal 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 };
|
||||
18
lib/www/client/source/src/store/modules/projects/getters.js
Normal file
18
lib/www/client/source/src/store/modules/projects/getters.js
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
const state = () => ({
|
||||
projects: Object.freeze([]),
|
||||
loading: null,
|
||||
timestamp: null,
|
||||
etag: null,
|
||||
});
|
||||
|
||||
export default state;
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
get: require('./get'),
|
||||
refresh: require('./refresh')
|
||||
};
|
||||
|
||||
23
lib/www/server/lib/db/project/summary/refresh.js
Normal file
23
lib/www/server/lib/db/project/summary/refresh.js
Normal 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;
|
||||
@@ -8,5 +8,6 @@ module.exports = {
|
||||
removeNulls: require('./removeNulls'),
|
||||
logicalPath: require('./logicalPath'),
|
||||
ranges: require('./ranges'),
|
||||
unique: require('./unique')
|
||||
unique: require('./unique'),
|
||||
setContentDisposition: require('./setContentDisposition')
|
||||
};
|
||||
|
||||
8
lib/www/server/lib/utils/setContentDisposition.js
Normal file
8
lib/www/server/lib/utils/setContentDisposition.js
Normal 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;
|
||||
@@ -11,7 +11,7 @@
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"config": {
|
||||
"db_schema": "^0.4.2",
|
||||
"db_schema": "^0.4.5",
|
||||
"api": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
|
||||
module.exports = [
|
||||
require('./purge-notifications')
|
||||
require('./purge-notifications'),
|
||||
require('./refresh-project-summary')
|
||||
];
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user