mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 12:57:08 +00:00
Compare commits
231 Commits
ptt
...
75-quality
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a08c93bc | ||
|
|
fabc9fe757 | ||
|
|
6f32f24481 | ||
|
|
dffe7defbb | ||
|
|
b9844528f1 | ||
|
|
cd78dbd0d8 | ||
|
|
798203be9f | ||
|
|
5bfd7dc835 | ||
|
|
c17862fbbb | ||
|
|
04c0369923 | ||
|
|
026cfb6f98 | ||
|
|
a4e6ec0712 | ||
|
|
b3e052cb12 | ||
|
|
cf88ecf172 | ||
|
|
e267440711 | ||
|
|
454094b187 | ||
|
|
862e754a6f | ||
|
|
894877750e | ||
|
|
09b45d5d65 | ||
|
|
1352c3b312 | ||
|
|
30aa2c302e | ||
|
|
3eaa2757b9 | ||
|
|
6f6af1bbc7 | ||
|
|
019561229c | ||
|
|
e212dc8b92 | ||
|
|
5c00013892 | ||
|
|
1e5bdcc068 | ||
|
|
a280a910f5 | ||
|
|
45fe467a21 | ||
|
|
8d3b7adc78 | ||
|
|
079d3a18b0 | ||
|
|
f0b1fc2fe6 | ||
|
|
987bdf6e21 | ||
|
|
1d3507b3a4 | ||
|
|
a82fc7bc8a | ||
|
|
29b3c9a250 | ||
|
|
040c1ead96 | ||
|
|
1c7bed0c15 | ||
|
|
dfcda1b2d9 | ||
|
|
b3aadfc33c | ||
|
|
d5980d9154 | ||
|
|
b5f2945c8b | ||
|
|
9bbffe2ae0 | ||
|
|
09f60d6c18 | ||
|
|
81d9ea19cc | ||
|
|
497d4d68f9 | ||
|
|
853deca3c3 | ||
|
|
99f1530db3 | ||
|
|
b325ae3452 | ||
|
|
f97d334fe5 | ||
|
|
cb114f01cd | ||
|
|
707df76b70 | ||
|
|
bba050032f | ||
|
|
594233c965 | ||
|
|
5795c1f87d | ||
|
|
ccd1852f65 | ||
|
|
17947df168 | ||
|
|
041878096d | ||
|
|
ea3e31058f | ||
|
|
534a54ef75 | ||
|
|
f314536daf | ||
|
|
de4aa52417 | ||
|
|
758b13b189 | ||
|
|
967db1dec6 | ||
|
|
91fd5e4559 | ||
|
|
cf171628cd | ||
|
|
94c29f4723 | ||
|
|
14b2e55a2e | ||
|
|
c30e54a515 | ||
|
|
7ead826677 | ||
|
|
7aecb514db | ||
|
|
ad395aa6e4 | ||
|
|
523ec937dd | ||
|
|
9d2ccd75dd | ||
|
|
3985a6226b | ||
|
|
7d354ffdb6 | ||
|
|
3d70a460ac | ||
|
|
caae656aae | ||
|
|
5708ed1a11 | ||
|
|
ad3998d4c6 | ||
|
|
8638f42e6d | ||
|
|
bc5aef5144 | ||
|
|
2b798c3ea3 | ||
|
|
4d97784829 | ||
|
|
13da38b4cd | ||
|
|
5af89050fb | ||
|
|
d40ceb8343 | ||
|
|
56d1279584 | ||
|
|
d02edb4e76 | ||
|
|
9875ae86f3 | ||
|
|
53f71f7005 | ||
|
|
5de64e6b45 | ||
|
|
67af85eca9 | ||
|
|
779b28a331 | ||
|
|
b9a4d18ed9 | ||
|
|
0dc9ac2b3c | ||
|
|
39d85a692b | ||
|
|
e7661bfd1c | ||
|
|
1649de6c68 | ||
|
|
1089d1fe75 | ||
|
|
fc58a4d435 | ||
|
|
c832d8b107 | ||
|
|
4a9e61be78 | ||
|
|
8cfd1a7fc9 | ||
|
|
315733eec0 | ||
|
|
ad422abe94 | ||
|
|
92210378e1 | ||
|
|
8d3e665206 | ||
|
|
4ee65ef284 | ||
|
|
d048a19066 | ||
|
|
97ed9bcce4 | ||
|
|
316117cb83 | ||
|
|
1d38f6526b | ||
|
|
6feb7d49ee | ||
|
|
ac51f72180 | ||
|
|
86d3323869 | ||
|
|
b181e4f424 | ||
|
|
7917eeeb0b | ||
|
|
b18907fb05 | ||
|
|
3e1861fcf6 | ||
|
|
820b0c2b91 | ||
|
|
57f4834da8 | ||
|
|
08d33e293a | ||
|
|
8e71b18225 | ||
|
|
f297458954 | ||
|
|
eb28648e57 | ||
|
|
0c352512b0 | ||
|
|
4d87506720 | ||
|
|
20bce40dac | ||
|
|
cf79cf86ae | ||
|
|
8e4f62e5be | ||
|
|
a8850e5d0c | ||
|
|
b5a762b5e3 | ||
|
|
418f1a00b8 | ||
|
|
0d9f7ac4ec | ||
|
|
76c9c3ef2a | ||
|
|
ef798860cd | ||
|
|
e57c362d94 | ||
|
|
7605b11fdb | ||
|
|
84e791fc66 | ||
|
|
3e2126cc32 | ||
|
|
b0f4559b83 | ||
|
|
c7e2e18cc8 | ||
|
|
42697fe91d | ||
|
|
900d7f7a3e | ||
|
|
f1953807db | ||
|
|
814e071698 | ||
|
|
2aba132220 | ||
|
|
15a802227d | ||
|
|
6745757712 | ||
|
|
9ff76867c9 | ||
|
|
e8811560de | ||
|
|
65b33a6b0f | ||
|
|
b8b5765b46 | ||
|
|
53f4e167f8 | ||
|
|
3d8f524d4a | ||
|
|
1e68676ac6 | ||
|
|
2c2d594877 | ||
|
|
fae849aeab | ||
|
|
1d47495799 | ||
|
|
592632d669 | ||
|
|
26c05b9e3c | ||
|
|
3f9a40724d | ||
|
|
a652a08815 | ||
|
|
61ffd1b766 | ||
|
|
d9f4583224 | ||
|
|
95647337aa | ||
|
|
b1e152179e | ||
|
|
142a820ed7 | ||
|
|
838b45ef26 | ||
|
|
30914b267a | ||
|
|
f1cbbdb56b | ||
|
|
9973e8f132 | ||
|
|
f53c479262 | ||
|
|
73a415a038 | ||
|
|
0b24e3224f | ||
|
|
c271256015 | ||
|
|
4887ddaa26 | ||
|
|
788c582f98 | ||
|
|
df9f7f33cf | ||
|
|
fd2e0399f8 | ||
|
|
db733ceef8 | ||
|
|
f905eb3fdf | ||
|
|
e707887702 | ||
|
|
c0ace1fe07 | ||
|
|
7bb3a3910b | ||
|
|
983113b6cc | ||
|
|
ff66c9a88d | ||
|
|
56d30d48c5 | ||
|
|
df3a0b4c50 | ||
|
|
f87aa08246 | ||
|
|
ea499a645b | ||
|
|
0fdb42c593 | ||
|
|
6e5584a433 | ||
|
|
0a4df0793d | ||
|
|
1e6cc67b05 | ||
|
|
3c4a558e02 | ||
|
|
76001cffe1 | ||
|
|
45a9c5aa07 | ||
|
|
f926184471 | ||
|
|
5ffd3712cf | ||
|
|
80451796e1 | ||
|
|
141d5805ae | ||
|
|
250ffe243d | ||
|
|
b4decd018a | ||
|
|
46d489c91f | ||
|
|
8a0bcc5cb4 | ||
|
|
77258b12e9 | ||
|
|
6896d8bc87 | ||
|
|
80b463fbb7 | ||
|
|
59aaacbeee | ||
|
|
3c86981dc6 | ||
|
|
5594b6863c | ||
|
|
7201c29df5 | ||
|
|
947736e8c1 | ||
|
|
d782a30e90 | ||
|
|
987dbb7700 | ||
|
|
cdd007ce88 | ||
|
|
a38066ec82 | ||
|
|
2aca34e488 | ||
|
|
324306a77d | ||
|
|
ab8a66bdcf | ||
|
|
b3f393a6f1 | ||
|
|
1ee886db63 | ||
|
|
fc9450434c | ||
|
|
00f4fcf292 | ||
|
|
0512ac2c3c | ||
|
|
dd32982cbe | ||
|
|
a3bfb73937 | ||
|
|
e0cd52f21a | ||
|
|
d902806c32 |
@@ -392,18 +392,34 @@ class Datastore:
|
||||
cursor.execute("BEGIN;")
|
||||
|
||||
hash = self.add_file(filepath, cursor)
|
||||
|
||||
if not records or len(records) == 0:
|
||||
print("File has no records (or none have been detected)")
|
||||
# We add the file to the database anyway to signal that we have
|
||||
# actually seen it.
|
||||
self.maybe_commit()
|
||||
return
|
||||
|
||||
incr = p111.point_number(records[0]) <= p111.point_number(records[-1])
|
||||
|
||||
# Start by deleting any online data we may have for this sequence
|
||||
self.del_hash("*online*", cursor)
|
||||
|
||||
qry = """
|
||||
INSERT INTO raw_lines (sequence, line, remarks, ntbp, incr)
|
||||
VALUES (%s, %s, '', %s, %s)
|
||||
INSERT INTO raw_lines (sequence, line, remarks, ntbp, incr, meta)
|
||||
VALUES (%s, %s, '', %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING;
|
||||
"""
|
||||
|
||||
cursor.execute(qry, (fileinfo["sequence"], fileinfo["line"], ntbp, incr))
|
||||
cursor.execute(qry, (fileinfo["sequence"], fileinfo["line"], ntbp, incr, json.dumps(fileinfo["meta"])))
|
||||
|
||||
qry = """
|
||||
UPDATE raw_lines
|
||||
SET meta = meta || %s
|
||||
WHERE sequence = %s;
|
||||
"""
|
||||
|
||||
cursor.execute(qry, (json.dumps(fileinfo["meta"]), fileinfo["sequence"]))
|
||||
|
||||
qry = """
|
||||
INSERT INTO raw_lines_files (sequence, hash)
|
||||
@@ -440,12 +456,20 @@ class Datastore:
|
||||
hash = self.add_file(filepath, cursor)
|
||||
|
||||
qry = """
|
||||
INSERT INTO final_lines (sequence, line, remarks)
|
||||
VALUES (%s, %s, '')
|
||||
INSERT INTO final_lines (sequence, line, remarks, meta)
|
||||
VALUES (%s, %s, '', %s)
|
||||
ON CONFLICT DO NOTHING;
|
||||
"""
|
||||
|
||||
cursor.execute(qry, (fileinfo["sequence"], fileinfo["line"]))
|
||||
cursor.execute(qry, (fileinfo["sequence"], fileinfo["line"], json.dumps(fileinfo["meta"])))
|
||||
|
||||
qry = """
|
||||
UPDATE raw_lines
|
||||
SET meta = meta || %s
|
||||
WHERE sequence = %s;
|
||||
"""
|
||||
|
||||
cursor.execute(qry, (json.dumps(fileinfo["meta"]), fileinfo["sequence"]))
|
||||
|
||||
qry = """
|
||||
INSERT INTO final_lines_files (sequence, hash)
|
||||
@@ -471,6 +495,8 @@ class Datastore:
|
||||
|
||||
if filedata is not None:
|
||||
self.save_file_data(filepath, json.dumps(filedata), cursor)
|
||||
|
||||
cursor.execute("CALL final_line_post_import(%s);", (fileinfo["sequence"],))
|
||||
|
||||
self.maybe_commit()
|
||||
|
||||
@@ -506,7 +532,7 @@ class Datastore:
|
||||
|
||||
qry = """
|
||||
UPDATE raw_shots
|
||||
SET meta = jsonb_set(meta, '{smsrc}', %s::jsonb, true)
|
||||
SET meta = jsonb_set(meta, '{smsrc}', %s::jsonb, true) - 'qc'
|
||||
WHERE sequence = %s AND point = %s;
|
||||
"""
|
||||
|
||||
@@ -631,3 +657,21 @@ class Datastore:
|
||||
self.maybe_commit()
|
||||
# We do not commit if we've been passed a cursor, instead
|
||||
# we assume that we are in the middle of a transaction
|
||||
|
||||
def del_sequence_final(self, sequence, cursor = None):
|
||||
"""
|
||||
Remove final data for a sequence.
|
||||
"""
|
||||
|
||||
if cursor is None:
|
||||
cur = self.conn.cursor()
|
||||
else:
|
||||
cur = cursor
|
||||
|
||||
qry = "DELETE FROM files WHERE hash = (SELECT hash FROM final_lines_files WHERE sequence = %s);"
|
||||
cur.execute(qry, (sequence,))
|
||||
if cursor is None:
|
||||
self.maybe_commit()
|
||||
# We do not commit if we've been passed a cursor, instead
|
||||
# we assume that we are in the middle of a transaction
|
||||
|
||||
|
||||
@@ -12,14 +12,45 @@ import os
|
||||
import sys
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import configuration
|
||||
import p111
|
||||
from datastore import Datastore
|
||||
|
||||
def add_pending_remark(db, sequence):
|
||||
text = '<!-- @@DGL:PENDING@@ --><h4 style="color:red;cursor:help;" title="Edit the sequence file or directory name to import final data">Marked as <code>PENDING</code>.</h4><!-- @@/DGL:PENDING@@ -->\n'
|
||||
|
||||
with db.conn.cursor() as cursor:
|
||||
qry = "SELECT remarks FROM raw_lines WHERE sequence = %s;"
|
||||
cursor.execute(qry, (sequence,))
|
||||
remarks = cursor.fetchone()[0]
|
||||
rx = re.compile("^(<!-- @@DGL:PENDING@@ -->.*<!-- @@/DGL:PENDING@@ -->\n)")
|
||||
m = rx.match(remarks)
|
||||
if m is None:
|
||||
remarks = text + remarks
|
||||
qry = "UPDATE raw_lines SET remarks = %s WHERE sequence = %s;"
|
||||
cursor.execute(qry, (remarks, sequence))
|
||||
db.maybe_commit()
|
||||
|
||||
def del_pending_remark(db, sequence):
|
||||
|
||||
with db.conn.cursor() as cursor:
|
||||
qry = "SELECT remarks FROM raw_lines WHERE sequence = %s;"
|
||||
cursor.execute(qry, (sequence,))
|
||||
remarks = cursor.fetchone()[0]
|
||||
rx = re.compile("^(<!-- @@DGL:PENDING@@ -->.*<!-- @@/DGL:PENDING@@ -->\n)")
|
||||
m = rx.match(remarks)
|
||||
if m is not None:
|
||||
remarks = rx.sub("",remarks)
|
||||
qry = "UPDATE raw_lines SET remarks = %s WHERE sequence = %s;"
|
||||
cursor.execute(qry, (remarks, sequence))
|
||||
db.maybe_commit()
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
print("Reading configuration")
|
||||
surveys = configuration.surveys()
|
||||
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
|
||||
|
||||
print("Connecting to database")
|
||||
db = Datastore()
|
||||
@@ -40,6 +71,9 @@ if __name__ == '__main__':
|
||||
pattern = final_p111["pattern"]
|
||||
rx = re.compile(pattern["regex"])
|
||||
|
||||
if "pending" in survey["final"]:
|
||||
pendingRx = re.compile(survey["final"]["pending"]["pattern"]["regex"])
|
||||
|
||||
for fileprefix in final_p111["paths"]:
|
||||
print(f"Path prefix: {fileprefix}")
|
||||
|
||||
@@ -48,7 +82,17 @@ if __name__ == '__main__':
|
||||
filepath = str(filepath)
|
||||
print(f"Found {filepath}")
|
||||
|
||||
pending = False
|
||||
if pendingRx:
|
||||
pending = pendingRx.search(filepath) is not None
|
||||
|
||||
if not db.file_in_db(filepath):
|
||||
|
||||
age = time.time() - os.path.getmtime(filepath)
|
||||
if age < file_min_age:
|
||||
print("Skipping file because too new", filepath)
|
||||
continue
|
||||
|
||||
print("Importing")
|
||||
|
||||
match = rx.match(os.path.basename(filepath))
|
||||
@@ -59,16 +103,30 @@ if __name__ == '__main__':
|
||||
continue
|
||||
|
||||
file_info = dict(zip(pattern["captures"], match.groups()))
|
||||
file_info["meta"] = {}
|
||||
|
||||
if pending:
|
||||
print("Skipping / removing final file because marked as PENDING", filepath)
|
||||
db.del_sequence_final(file_info["sequence"])
|
||||
add_pending_remark(db, file_info["sequence"])
|
||||
continue
|
||||
else:
|
||||
del_pending_remark(db, file_info["sequence"])
|
||||
|
||||
p111_data = p111.from_file(filepath)
|
||||
|
||||
print("Saving")
|
||||
|
||||
p111_records = p111.p111_type("S", p111_data)
|
||||
file_info["meta"]["lineName"] = p111.line_name(p111_data)
|
||||
|
||||
db.save_final_p111(p111_records, file_info, filepath, survey["epsg"])
|
||||
else:
|
||||
print("Already in DB")
|
||||
if pending:
|
||||
print("Removing from database because marked as PENDING")
|
||||
db.del_sequence_final(file_info["sequence"])
|
||||
add_pending_remark(db, file_info["sequence"])
|
||||
|
||||
print("Done")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import sys
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import configuration
|
||||
import p190
|
||||
from datastore import Datastore
|
||||
@@ -20,6 +21,7 @@ if __name__ == '__main__':
|
||||
|
||||
print("Reading configuration")
|
||||
surveys = configuration.surveys()
|
||||
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
|
||||
|
||||
print("Connecting to database")
|
||||
db = Datastore()
|
||||
@@ -49,6 +51,12 @@ if __name__ == '__main__':
|
||||
print(f"Found {filepath}")
|
||||
|
||||
if not db.file_in_db(filepath):
|
||||
|
||||
age = time.time() - os.path.getmtime(filepath)
|
||||
if age < file_min_age:
|
||||
print("Skipping file because too new", filepath)
|
||||
continue
|
||||
|
||||
print("Importing")
|
||||
|
||||
match = rx.match(os.path.basename(filepath))
|
||||
|
||||
@@ -8,7 +8,9 @@ or modified preplots and (re-)import them into the database.
|
||||
"""
|
||||
|
||||
from glob import glob
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import configuration
|
||||
import preplots
|
||||
from datastore import Datastore
|
||||
@@ -17,6 +19,7 @@ if __name__ == '__main__':
|
||||
|
||||
print("Reading configuration")
|
||||
surveys = configuration.surveys()
|
||||
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
|
||||
|
||||
print("Connecting to database")
|
||||
db = Datastore()
|
||||
@@ -28,6 +31,12 @@ if __name__ == '__main__':
|
||||
for file in survey["preplots"]:
|
||||
print(f"Preplot: {file['path']}")
|
||||
if not db.file_in_db(file["path"]):
|
||||
|
||||
age = time.time() - os.path.getmtime(file["path"])
|
||||
if age < file_min_age:
|
||||
print("Skipping file because too new", file["path"])
|
||||
continue
|
||||
|
||||
print("Importing")
|
||||
try:
|
||||
preplot = preplots.from_file(file)
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import sys
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import configuration
|
||||
import p111
|
||||
from datastore import Datastore
|
||||
@@ -20,6 +21,7 @@ if __name__ == '__main__':
|
||||
|
||||
print("Reading configuration")
|
||||
surveys = configuration.surveys()
|
||||
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
|
||||
|
||||
print("Connecting to database")
|
||||
db = Datastore()
|
||||
@@ -57,6 +59,12 @@ if __name__ == '__main__':
|
||||
ntbp = False
|
||||
|
||||
if not db.file_in_db(filepath):
|
||||
|
||||
age = time.time() - os.path.getmtime(filepath)
|
||||
if age < file_min_age:
|
||||
print("Skipping file because too new", filepath)
|
||||
continue
|
||||
|
||||
print("Importing")
|
||||
|
||||
match = rx.match(os.path.basename(filepath))
|
||||
@@ -67,12 +75,14 @@ if __name__ == '__main__':
|
||||
continue
|
||||
|
||||
file_info = dict(zip(pattern["captures"], match.groups()))
|
||||
file_info["meta"] = {}
|
||||
|
||||
p111_data = p111.from_file(filepath)
|
||||
|
||||
print("Saving")
|
||||
|
||||
p111_records = p111.p111_type("S", p111_data)
|
||||
file_info["meta"]["lineName"] = p111.line_name(p111_data)
|
||||
|
||||
db.save_raw_p111(p111_records, file_info, filepath, survey["epsg"], ntbp=ntbp)
|
||||
else:
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import sys
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import configuration
|
||||
import p190
|
||||
from datastore import Datastore
|
||||
@@ -20,6 +21,7 @@ if __name__ == '__main__':
|
||||
|
||||
print("Reading configuration")
|
||||
surveys = configuration.surveys()
|
||||
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
|
||||
|
||||
print("Connecting to database")
|
||||
db = Datastore()
|
||||
@@ -52,6 +54,12 @@ if __name__ == '__main__':
|
||||
print(f"Found {filepath}")
|
||||
|
||||
if not db.file_in_db(filepath):
|
||||
|
||||
age = time.time() - os.path.getmtime(filepath)
|
||||
if age < file_min_age:
|
||||
print("Skipping file because too new", filepath)
|
||||
continue
|
||||
|
||||
print("Importing")
|
||||
|
||||
match = rx.match(os.path.basename(filepath))
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import sys
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import configuration
|
||||
import smsrc
|
||||
from datastore import Datastore
|
||||
@@ -20,6 +21,7 @@ if __name__ == '__main__':
|
||||
|
||||
print("Reading configuration")
|
||||
surveys = configuration.surveys()
|
||||
file_min_age = configuration.read().get('imports', {}).get('file_min_age', 10)
|
||||
|
||||
print("Connecting to database")
|
||||
db = Datastore()
|
||||
@@ -53,6 +55,12 @@ if __name__ == '__main__':
|
||||
print(f"Found {filepath}")
|
||||
|
||||
if not db.file_in_db(filepath):
|
||||
|
||||
age = time.time() - os.path.getmtime(filepath)
|
||||
if age < file_min_age:
|
||||
print("Skipping file because too new", filepath)
|
||||
continue
|
||||
|
||||
print("Importing")
|
||||
|
||||
match = rx.match(os.path.basename(filepath))
|
||||
|
||||
48
bin/insert_event.py
Executable file
48
bin/insert_event.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
from datetime import datetime
|
||||
from datastore import Datastore
|
||||
|
||||
def detect_schema (conn):
|
||||
with conn.cursor() as cursor:
|
||||
qry = "SELECT meta->>'_schema' AS schema, tstamp, age(current_timestamp, tstamp) age FROM real_time_inputs WHERE meta ? '_schema' AND age(current_timestamp, tstamp) < '02:00:00' ORDER BY tstamp DESC LIMIT 1"
|
||||
cursor.execute(qry)
|
||||
res = cursor.fetchone()
|
||||
if res and len(res):
|
||||
return res[0]
|
||||
return None
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("-s", "--schema", required=False, default=None, help="survey where to insert the event")
|
||||
ap.add_argument("-t", "--tstamp", required=False, default=None, help="event timestamp")
|
||||
ap.add_argument("-l", "--label", required=False, default=None, action="append", help="event label")
|
||||
ap.add_argument('remarks', type=str, nargs="+", help="event message")
|
||||
args = vars(ap.parse_args())
|
||||
|
||||
|
||||
db = Datastore()
|
||||
db.connect()
|
||||
|
||||
if args["schema"]:
|
||||
schema = args["schema"]
|
||||
else:
|
||||
schema = detect_schema(db.conn)
|
||||
|
||||
if args["tstamp"]:
|
||||
tstamp = args["tstamp"]
|
||||
else:
|
||||
tstamp = datetime.utcnow().isoformat()
|
||||
|
||||
message = " ".join(args["remarks"])
|
||||
|
||||
print("new event:", schema, tstamp, message)
|
||||
|
||||
if schema and tstamp and message:
|
||||
db.set_survey(schema)
|
||||
with db.conn.cursor() as cursor:
|
||||
qry = "INSERT INTO events_timed (tstamp, remarks) VALUES (%s, %s);"
|
||||
cursor.execute(qry, (tstamp, message))
|
||||
db.maybe_commit()
|
||||
@@ -153,6 +153,9 @@ def parse_line (string):
|
||||
return None
|
||||
|
||||
|
||||
def line_name(records):
|
||||
return set([ r['Acquisition Line Name'] for r in p111_type("S", records) ]).pop()
|
||||
|
||||
def p111_type(type, records):
|
||||
return [ r for r in records if r["type"] == type ]
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ locals().update(configuration.vars())
|
||||
exportables = {
|
||||
"public": {
|
||||
"projects": [ "meta" ],
|
||||
"info": None,
|
||||
"real_time_inputs": None
|
||||
},
|
||||
"survey": {
|
||||
@@ -32,7 +33,8 @@ exportables = {
|
||||
"preplot_lines": [ "remarks", "ntba", "meta" ],
|
||||
"preplot_points": [ "ntba", "meta" ],
|
||||
"raw_lines": [ "remarks", "meta" ],
|
||||
"raw_shots": [ "meta" ]
|
||||
"raw_shots": [ "meta" ],
|
||||
"planned_lines": None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ if __name__ == '__main__':
|
||||
continue
|
||||
|
||||
try:
|
||||
for table in exportables:
|
||||
path = os.path.join(pathPrefix, table)
|
||||
if os.path.exists(path):
|
||||
cursor.execute(f"DELETE FROM {table};")
|
||||
for table in exportables:
|
||||
path = os.path.join(pathPrefix, table)
|
||||
print(" ← ", path, " → ", table)
|
||||
|
||||
@@ -19,6 +19,7 @@ locals().update(configuration.vars())
|
||||
exportables = {
|
||||
"public": {
|
||||
"projects": [ "meta" ],
|
||||
"info": None,
|
||||
"real_time_inputs": None
|
||||
},
|
||||
"survey": {
|
||||
@@ -27,7 +28,8 @@ exportables = {
|
||||
"preplot_lines": [ "remarks", "ntba", "meta" ],
|
||||
"preplot_points": [ "ntba", "meta" ],
|
||||
"raw_lines": [ "remarks", "meta" ],
|
||||
"raw_shots": [ "meta" ]
|
||||
"raw_shots": [ "meta" ],
|
||||
"planned_lines": None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,16 +98,21 @@ if __name__ == '__main__':
|
||||
with db.conn.cursor() as cursor:
|
||||
columns = exportables["public"][table]
|
||||
path = os.path.join(VARDIR, "-"+table)
|
||||
with open(path, "rb") as fd:
|
||||
print(" →→ ", path, " ←← ", table, columns)
|
||||
if columns is not None:
|
||||
import_table(fd, table, columns, cursor)
|
||||
else:
|
||||
try:
|
||||
print(f"Copying from {path} into {table}")
|
||||
cursor.copy_from(fd, table)
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
print(f"It looks like table {table} may have already been imported. Skipping it.")
|
||||
try:
|
||||
with open(path, "rb") as fd:
|
||||
print(" →→ ", path, " ←← ", table, columns)
|
||||
if columns is not None:
|
||||
import_table(fd, table, columns, cursor)
|
||||
else:
|
||||
try:
|
||||
print(f"Copying from {path} into {table}")
|
||||
cursor.copy_from(fd, table)
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
print(f"It looks like table {table} may have already been imported. Skipping it.")
|
||||
except FileNotFoundError:
|
||||
print(f"File not found. Skipping {path}")
|
||||
|
||||
db.conn.commit()
|
||||
|
||||
print("Reading surveys")
|
||||
for survey in surveys:
|
||||
@@ -124,15 +131,18 @@ if __name__ == '__main__':
|
||||
path = os.path.join(pathPrefix, "-"+table)
|
||||
print(" ←← ", path, " →→ ", table, columns)
|
||||
|
||||
with open(path, "rb") as fd:
|
||||
if columns is not None:
|
||||
import_table(fd, table, columns, cursor)
|
||||
else:
|
||||
try:
|
||||
print(f"Copying from {path} into {table}")
|
||||
cursor.copy_from(fd, table)
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
print(f"It looks like table {table} may have already been imported. Skipping it.")
|
||||
try:
|
||||
with open(path, "rb") as fd:
|
||||
if columns is not None:
|
||||
import_table(fd, table, columns, cursor)
|
||||
else:
|
||||
try:
|
||||
print(f"Copying from {path} into {table}")
|
||||
cursor.copy_from(fd, table)
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
print(f"It looks like table {table} may have already been imported. Skipping it.")
|
||||
except FileNotFoundError:
|
||||
print(f"File not found. Skipping {path}")
|
||||
|
||||
# If we don't commit the data does not actually get copied
|
||||
db.conn.commit()
|
||||
|
||||
@@ -21,4 +21,14 @@ navigation:
|
||||
# Anything here gets passed as options to the packet
|
||||
# saving routine.
|
||||
epsg: 23031 # Assume this CRS for unqualified E/N data
|
||||
# Heuristics to apply to detect survey when offline
|
||||
offline_survey_heuristics: "nearest_preplot"
|
||||
# Apply the heuristics at most once every…
|
||||
offline_survey_detect_interval: 10000 # ms
|
||||
|
||||
|
||||
imports:
|
||||
# For a file to be imported, it must have been last modified at
|
||||
# least this many seconds ago.
|
||||
file_min_age: 60
|
||||
|
||||
|
||||
@@ -226,6 +226,18 @@ CREATE TABLE public.real_time_inputs (
|
||||
|
||||
ALTER TABLE public.real_time_inputs OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: info; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.info (
|
||||
key text NOT NULL,
|
||||
value jsonb
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.info OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: projects projects_name_key; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
@@ -250,6 +262,16 @@ ALTER TABLE ONLY public.projects
|
||||
ADD CONSTRAINT projects_schema_key UNIQUE (schema);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: info info_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.info
|
||||
ADD CONSTRAINT info_pkey PRIMARY KEY (key);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: tstamp_idx; Type: INDEX; Schema: public; Owner: postgres
|
||||
--
|
||||
@@ -271,6 +293,13 @@ CREATE TRIGGER projects_tg AFTER INSERT OR DELETE OR UPDATE ON public.projects F
|
||||
CREATE TRIGGER real_time_inputs_tg AFTER INSERT ON public.real_time_inputs FOR EACH ROW EXECUTE FUNCTION public.notify('realtime');
|
||||
|
||||
|
||||
--
|
||||
-- Name: info info_tg; Type: TRIGGER; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TRIGGER info_tg AFTER INSERT OR DELETE OR UPDATE ON public.info FOR EACH ROW EXECUTE FUNCTION public.notify('info');
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
-- Dumped from database version 12.4
|
||||
-- Dumped by pg_dump version 12.4
|
||||
-- Dumped from database version 12.6
|
||||
-- Dumped by pg_dump version 12.6
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
@@ -63,6 +63,185 @@ If the hash matches that of an existing entry, update the path of that entry to
|
||||
If the path matches that of an existing entry, delete that entry (which cascades) and insert the new one.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: adjust_planner(); Type: PROCEDURE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE PROCEDURE _SURVEY__TEMPLATE_.adjust_planner()
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
_planner_config jsonb;
|
||||
_planned_line planned_lines%ROWTYPE;
|
||||
_lag interval;
|
||||
_last_sequence sequences_summary%ROWTYPE;
|
||||
_deltatime interval;
|
||||
_shotinterval interval;
|
||||
_tstamp timestamptz;
|
||||
_incr integer;
|
||||
BEGIN
|
||||
|
||||
SET CONSTRAINTS planned_lines_pkey DEFERRED;
|
||||
|
||||
SELECT data->'planner'
|
||||
INTO _planner_config
|
||||
FROM file_data
|
||||
WHERE data ? 'planner';
|
||||
|
||||
SELECT *
|
||||
INTO _last_sequence
|
||||
FROM sequences_summary
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 1;
|
||||
|
||||
SELECT *
|
||||
INTO _planned_line
|
||||
FROM planned_lines
|
||||
WHERE sequence = _last_sequence.sequence AND line = _last_sequence.line;
|
||||
|
||||
SELECT
|
||||
COALESCE(
|
||||
((lead(ts0) OVER (ORDER BY sequence)) - ts1),
|
||||
make_interval(mins => (_planner_config->>'defaultLineChangeDuration')::integer)
|
||||
)
|
||||
INTO _lag
|
||||
FROM planned_lines
|
||||
WHERE sequence = _last_sequence.sequence AND line = _last_sequence.line;
|
||||
|
||||
_incr = sign(_last_sequence.lsp - _last_sequence.fsp);
|
||||
|
||||
RAISE NOTICE '_planner_config: %', _planner_config;
|
||||
RAISE NOTICE '_last_sequence: %', _last_sequence;
|
||||
RAISE NOTICE '_planned_line: %', _planned_line;
|
||||
RAISE NOTICE '_incr: %', _incr;
|
||||
|
||||
-- Does the latest sequence match a planned sequence?
|
||||
IF _planned_line IS NULL THEN -- No it doesn't
|
||||
RAISE NOTICE 'Latest sequence shot does not match a planned sequence';
|
||||
SELECT * INTO _planned_line FROM planned_lines ORDER BY sequence ASC LIMIT 1;
|
||||
RAISE NOTICE '_planned_line: %', _planned_line;
|
||||
|
||||
IF _planned_line.sequence <= _last_sequence.sequence THEN
|
||||
RAISE NOTICE 'Renumbering the planned sequences starting from %', _planned_line.sequence + 1;
|
||||
-- Renumber the planned sequences starting from last shot sequence number + 1
|
||||
UPDATE planned_lines
|
||||
SET sequence = sequence + _last_sequence.sequence - _planned_line.sequence + 1;
|
||||
END IF;
|
||||
|
||||
-- The correction to make to the first planned line's ts0 will be based on either the last
|
||||
-- sequence's EOL + default line change time or the current time, whichever is later.
|
||||
_deltatime := GREATEST(COALESCE(_last_sequence.ts1_final, _last_sequence.ts1) + make_interval(mins => (_planner_config->>'defaultLineChangeDuration')::integer), current_timestamp) - _planned_line.ts0;
|
||||
|
||||
-- Is the first of the planned lines start time in the past? (±5 mins)
|
||||
IF _planned_line.ts0 < (current_timestamp - make_interval(mins => 5)) THEN
|
||||
RAISE NOTICE 'First planned line is in the past. Adjusting times by %', _deltatime;
|
||||
-- Adjust the start / end time of the planned lines by assuming that we are at
|
||||
-- `defaultLineChangeDuration` minutes away from SOL of the first planned line.
|
||||
UPDATE planned_lines
|
||||
SET
|
||||
ts0 = ts0 + _deltatime,
|
||||
ts1 = ts1 + _deltatime;
|
||||
END IF;
|
||||
|
||||
ELSE -- Yes it does
|
||||
RAISE NOTICE 'Latest sequence does match a planned sequence: %, %', _planned_line.sequence, _planned_line.line;
|
||||
|
||||
-- Is it online?
|
||||
IF EXISTS(SELECT 1 FROM raw_lines_files WHERE sequence = _last_sequence.sequence AND hash = '*online*') THEN
|
||||
-- Yes it is
|
||||
RAISE NOTICE 'Sequence % is online', _last_sequence.sequence;
|
||||
|
||||
-- Let us get the SOL from the events log if we can
|
||||
RAISE NOTICE 'Trying to set fsp, ts0 from events log FSP, FGSP';
|
||||
WITH e AS (
|
||||
SELECT * FROM events
|
||||
WHERE
|
||||
sequence = _last_sequence.sequence
|
||||
AND ('FSP' = ANY(labels) OR 'FGSP' = ANY(labels))
|
||||
ORDER BY tstamp LIMIT 1
|
||||
)
|
||||
UPDATE planned_lines
|
||||
SET
|
||||
fsp = COALESCE(e.point, fsp),
|
||||
ts0 = COALESCE(e.tstamp, ts0)
|
||||
FROM e
|
||||
WHERE planned_lines.sequence = _last_sequence.sequence;
|
||||
|
||||
-- Shot interval
|
||||
_shotinterval := (_last_sequence.ts1 - _last_sequence.ts0) / abs(_last_sequence.lsp - _last_sequence.fsp);
|
||||
|
||||
RAISE NOTICE 'Estimating EOL from current shot interval: %', _shotinterval;
|
||||
|
||||
SELECT (abs(lsp-fsp) * _shotinterval + ts0) - ts1
|
||||
INTO _deltatime
|
||||
FROM planned_lines
|
||||
WHERE sequence = _last_sequence.sequence;
|
||||
|
||||
---- Set ts1 for the current sequence
|
||||
--UPDATE planned_lines
|
||||
--SET
|
||||
--ts1 = (abs(lsp-fsp) * _shotinterval) + ts0
|
||||
--WHERE sequence = _last_sequence.sequence;
|
||||
|
||||
RAISE NOTICE 'Adjustment is %', _deltatime;
|
||||
|
||||
IF EXTRACT(EPOCH FROM _deltatime) < 8 THEN
|
||||
RAISE NOTICE 'Adjustment too small (< 8 s), so not applying it';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Adjust ts1 for the current sequence
|
||||
UPDATE planned_lines
|
||||
SET ts1 = ts1 + _deltatime
|
||||
WHERE sequence = _last_sequence.sequence;
|
||||
|
||||
-- Now shift all sequences after
|
||||
UPDATE planned_lines
|
||||
SET ts0 = ts0 + _deltatime, ts1 = ts1 + _deltatime
|
||||
WHERE sequence > _last_sequence.sequence;
|
||||
|
||||
RAISE NOTICE 'Deleting planned sequences before %', _planned_line.sequence;
|
||||
-- Remove all previous planner entries.
|
||||
DELETE
|
||||
FROM planned_lines
|
||||
WHERE sequence < _last_sequence.sequence;
|
||||
|
||||
ELSE
|
||||
-- No it isn't
|
||||
RAISE NOTICE 'Sequence % is offline', _last_sequence.sequence;
|
||||
|
||||
-- We were supposed to finish at _planned_line.ts1 but we finished at:
|
||||
_tstamp := GREATEST(COALESCE(_last_sequence.ts1_final, _last_sequence.ts1), current_timestamp);
|
||||
-- WARNING Next line is for testing only
|
||||
--_tstamp := COALESCE(_last_sequence.ts1_final, _last_sequence.ts1);
|
||||
-- So we need to adjust timestamps by:
|
||||
_deltatime := _tstamp - _planned_line.ts1;
|
||||
|
||||
RAISE NOTICE 'Planned end: %, actual end: % (%, %)', _planned_line.ts1, _tstamp, _planned_line.sequence, _last_sequence.sequence;
|
||||
RAISE NOTICE 'Shifting times by % for sequences > %', _deltatime, _planned_line.sequence;
|
||||
-- NOTE: This won't work if sequences are not, err… sequential.
|
||||
-- NOTE: This has been known to happen in 2020.
|
||||
UPDATE planned_lines
|
||||
SET
|
||||
ts0 = ts0 + _deltatime,
|
||||
ts1 = ts1 + _deltatime
|
||||
WHERE sequence > _planned_line.sequence;
|
||||
|
||||
RAISE NOTICE 'Deleting planned sequences up to %', _planned_line.sequence;
|
||||
-- Remove all previous planner entries.
|
||||
DELETE
|
||||
FROM planned_lines
|
||||
WHERE sequence <= _last_sequence.sequence;
|
||||
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER PROCEDURE _SURVEY__TEMPLATE_.adjust_planner() OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: assoc_tstamp(); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
@@ -136,6 +315,38 @@ $$;
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.clear_shot_qc() OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: events_seq_labels_single(); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.events_seq_labels_single() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE _sequence integer;
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM labels WHERE name = NEW.label AND (data->'model'->'multiple')::boolean IS FALSE) THEN
|
||||
SELECT sequence INTO _sequence FROM events WHERE id = NEW.id;
|
||||
DELETE
|
||||
FROM events_seq_labels
|
||||
WHERE
|
||||
id <> NEW.id
|
||||
AND label = NEW.label
|
||||
AND id IN (SELECT id FROM events_seq WHERE sequence = _sequence);
|
||||
|
||||
DELETE
|
||||
FROM events_timed_labels
|
||||
WHERE
|
||||
id <> NEW.id
|
||||
AND label = NEW.label
|
||||
AND id IN (SELECT id FROM events_timed_seq WHERE sequence = _sequence);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.events_seq_labels_single() OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: events_timed_seq_match(); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
@@ -213,82 +424,102 @@ $$;
|
||||
ALTER PROCEDURE _SURVEY__TEMPLATE_.events_timed_seq_update_all() OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: reset_events_serials(); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
-- Name: final_line_post_import(integer); Type: PROCEDURE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.reset_events_serials() RETURNS void
|
||||
CREATE PROCEDURE _SURVEY__TEMPLATE_.final_line_post_import(_seq integer)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM setval('events_timed_id_seq', (SELECT max(id)+1 FROM events_timed));
|
||||
PERFORM setval('events_seq_id_seq', (SELECT max(id)+1 FROM events_seq));
|
||||
|
||||
CALL handle_final_line_events(_seq, 'FSP', 'fsp');
|
||||
CALL handle_final_line_events(_seq, 'FGSP', 'fsp');
|
||||
CALL handle_final_line_events(_seq, 'LGSP', 'lsp');
|
||||
CALL handle_final_line_events(_seq, 'LSP', 'lsp');
|
||||
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.reset_events_serials() OWNER TO postgres;
|
||||
ALTER PROCEDURE _SURVEY__TEMPLATE_.final_line_post_import(_seq integer) OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: to_binning_grid(public.geometry); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
-- Name: handle_final_line_events(integer, text, text); Type: PROCEDURE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry) RETURNS public.geometry
|
||||
LANGUAGE plpgsql STABLE LEAKPROOF
|
||||
AS $$DECLARE
|
||||
bp jsonb := binning_parameters();
|
||||
theta numeric := (bp->>'theta')::numeric * pi() / 180;
|
||||
I_inc numeric DEFAULT 1;
|
||||
J_inc numeric DEFAULT 1;
|
||||
I_width numeric := (bp->>'I_width')::numeric;
|
||||
J_width numeric := (bp->>'J_width')::numeric;
|
||||
CREATE PROCEDURE _SURVEY__TEMPLATE_.handle_final_line_events(_seq integer, _label text, _column text)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
|
||||
a numeric := (I_inc/I_width) * cos(theta);
|
||||
b numeric := (I_inc/I_width) * -sin(theta);
|
||||
c numeric := (J_inc/J_width) * sin(theta);
|
||||
d numeric := (J_inc/J_width) * cos(theta);
|
||||
xoff numeric := (bp->'origin'->>'I')::numeric;
|
||||
yoff numeric := (bp->'origin'->>'J')::numeric;
|
||||
E0 numeric := (bp->'origin'->>'easting')::numeric;
|
||||
N0 numeric := (bp->'origin'->>'northing')::numeric;
|
||||
DECLARE
|
||||
_line final_lines_summary%ROWTYPE;
|
||||
_column_value integer;
|
||||
_tg_name text := 'final_line';
|
||||
_event events%ROWTYPE;
|
||||
event_id integer;
|
||||
BEGIN
|
||||
-- RAISE NOTICE 'Matrix: a: %, b: %, c: %, d: %, xoff: %, yoff: %', a, b, c, d, xoff, yoff;
|
||||
RETURN ST_SetSRID(ST_Affine(ST_Translate(geom, -E0, -N0), a, b, c, d, xoff, yoff), 0);
|
||||
END
|
||||
|
||||
SELECT * INTO _line FROM final_lines_summary WHERE sequence = _seq;
|
||||
_event := label_in_sequence(_seq, _label);
|
||||
_column_value := row_to_json(_line)->>_column;
|
||||
|
||||
--RAISE NOTICE '% is %', _label, _event;
|
||||
--RAISE NOTICE 'Line is %', _line;
|
||||
--RAISE NOTICE '% is % (%)', _column, _column_value, _label;
|
||||
|
||||
IF _event IS NULL THEN
|
||||
--RAISE NOTICE 'We will populate the event log from the sequence data';
|
||||
|
||||
SELECT id INTO event_id FROM events_seq WHERE sequence = _seq AND point = _column_value ORDER BY id LIMIT 1;
|
||||
IF event_id IS NULL THEN
|
||||
--RAISE NOTICE '… but there is no existing event so we create a new one for sequence % and point %', _line.sequence, _column_value;
|
||||
INSERT INTO events_seq (sequence, point, remarks)
|
||||
VALUES (_line.sequence, _column_value, format('%s %s', _label, (SELECT meta->>'lineName' FROM final_lines WHERE sequence = _seq)))
|
||||
RETURNING id INTO event_id;
|
||||
--RAISE NOTICE 'Created event_id %', event_id;
|
||||
END IF;
|
||||
|
||||
--RAISE NOTICE 'Remove any other auto-inserted % labels in sequence %', _label, _seq;
|
||||
DELETE FROM events_seq_labels
|
||||
WHERE label = _label AND id = (SELECT id FROM events_seq WHERE sequence = _seq AND meta->'auto' ? _label);
|
||||
|
||||
--RAISE NOTICE 'We now add a label to the event (id, label) = (%, %)', event_id, _label;
|
||||
INSERT INTO events_seq_labels (id, label) VALUES (event_id, _label) ON CONFLICT ON CONSTRAINT events_seq_labels_pkey DO NOTHING;
|
||||
|
||||
--RAISE NOTICE 'And also clear the %: % flag from meta.auto for any existing events for sequence %', _label, _tg_name, _seq;
|
||||
UPDATE events_seq
|
||||
SET meta = meta #- ARRAY['auto', _label]
|
||||
WHERE meta->'auto' ? _label AND sequence = _seq AND id <> event_id;
|
||||
|
||||
--RAISE NOTICE 'Finally, flag the event as having been had label % auto-created by %', _label, _tg_name;
|
||||
UPDATE events_seq
|
||||
SET meta = jsonb_set(jsonb_set(meta, '{auto}', COALESCE(meta->'auto', '{}')), ARRAY['auto', _label], to_jsonb(_tg_name))
|
||||
WHERE id = event_id;
|
||||
|
||||
ELSE
|
||||
--RAISE NOTICE 'We may populate the sequence meta from the event log';
|
||||
--RAISE NOTICE 'Unless the event log was populated by us previously';
|
||||
--RAISE NOTICE 'Populated by us previously? %', _event.meta->'auto'->>_label = _tg_name;
|
||||
|
||||
IF _event.meta->'auto'->>_label IS DISTINCT FROM _tg_name THEN
|
||||
--RAISE NOTICE 'Adding % found in events log to final_line meta', _label;
|
||||
UPDATE final_lines
|
||||
SET meta = jsonb_set(meta, ARRAY[_label], to_jsonb(_event.point))
|
||||
WHERE sequence = _seq;
|
||||
|
||||
--RAISE NOTICE 'Clearing the %: % flag from meta.auto for any existing events in sequence %', _label, _tg_name, _seq;
|
||||
UPDATE events_seq
|
||||
SET meta = meta #- ARRAY['auto', _label]
|
||||
WHERE sequence = _seq AND meta->'auto'->>_label = _tg_name;
|
||||
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry) OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: to_binning_grid(public.geometry, jsonb); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry, bp jsonb) RETURNS public.geometry
|
||||
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
||||
AS $$DECLARE
|
||||
-- bp jsonb := binning_parameters();
|
||||
theta numeric := (bp->>'theta')::numeric * pi() / 180;
|
||||
I_inc numeric DEFAULT 1;
|
||||
J_inc numeric DEFAULT 1;
|
||||
I_width numeric := (bp->>'I_width')::numeric;
|
||||
J_width numeric := (bp->>'J_width')::numeric;
|
||||
|
||||
a numeric := (I_inc/I_width) * cos(theta);
|
||||
b numeric := (I_inc/I_width) * -sin(theta);
|
||||
c numeric := (J_inc/J_width) * sin(theta);
|
||||
d numeric := (J_inc/J_width) * cos(theta);
|
||||
xoff numeric := (bp->'origin'->>'I')::numeric;
|
||||
yoff numeric := (bp->'origin'->>'J')::numeric;
|
||||
E0 numeric := (bp->'origin'->>'easting')::numeric;
|
||||
N0 numeric := (bp->'origin'->>'northing')::numeric;
|
||||
BEGIN
|
||||
-- RAISE NOTICE 'Matrix: a: %, b: %, c: %, d: %, xoff: %, yoff: %', a, b, c, d, xoff, yoff;
|
||||
RETURN ST_SetSRID(ST_Affine(ST_Translate(geom, -E0, -N0), a, b, c, d, xoff, yoff), 0);
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry, bp jsonb) OWNER TO postgres;
|
||||
ALTER PROCEDURE _SURVEY__TEMPLATE_.handle_final_line_events(_seq integer, _label text, _column text) OWNER TO postgres;
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
@@ -430,6 +661,7 @@ CREATE VIEW _SURVEY__TEMPLATE_.events_seq_timed AS
|
||||
rs.objref,
|
||||
rs.tstamp,
|
||||
rs.hash,
|
||||
s.meta,
|
||||
rs.geometry
|
||||
FROM (_SURVEY__TEMPLATE_.events_seq s
|
||||
LEFT JOIN _SURVEY__TEMPLATE_.raw_shots rs USING (sequence, point));
|
||||
@@ -524,6 +756,7 @@ CREATE VIEW _SURVEY__TEMPLATE_.events AS
|
||||
s.objref,
|
||||
s.tstamp,
|
||||
s.hash,
|
||||
s.meta,
|
||||
(public.st_asgeojson(public.st_transform(s.geometry, 4326)))::jsonb AS geometry,
|
||||
ARRAY( SELECT esl.label
|
||||
FROM _SURVEY__TEMPLATE_.events_seq_labels esl
|
||||
@@ -540,6 +773,7 @@ UNION
|
||||
rs.objref,
|
||||
t.tstamp,
|
||||
rs.hash,
|
||||
t.meta,
|
||||
(t.meta -> 'geometry'::text) AS geometry,
|
||||
ARRAY( SELECT etl.label
|
||||
FROM _SURVEY__TEMPLATE_.events_timed_labels etl
|
||||
@@ -558,6 +792,7 @@ UNION
|
||||
v1.objref,
|
||||
v1.tstamp,
|
||||
v1.hash,
|
||||
'{}'::jsonb AS meta,
|
||||
(public.st_asgeojson(public.st_transform(v1.geometry, 4326)))::jsonb AS geometry,
|
||||
ARRAY[v1.label] AS labels
|
||||
FROM _SURVEY__TEMPLATE_.events_midnight_shot v1
|
||||
@@ -572,6 +807,7 @@ UNION
|
||||
rs.objref,
|
||||
rs.tstamp,
|
||||
rs.hash,
|
||||
'{}'::jsonb AS meta,
|
||||
(public.st_asgeojson(public.st_transform(rs.geometry, 4326)))::jsonb AS geometry,
|
||||
('{QC}'::text[] || qc.labels) AS labels
|
||||
FROM (_SURVEY__TEMPLATE_.raw_shots rs
|
||||
@@ -582,6 +818,97 @@ UNION
|
||||
|
||||
ALTER TABLE _SURVEY__TEMPLATE_.events OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: label_in_sequence(integer, text); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.label_in_sequence(_sequence integer, _label text) RETURNS _SURVEY__TEMPLATE_.events
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
SELECT * FROM events WHERE sequence = _sequence AND _label = ANY(labels);
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.label_in_sequence(_sequence integer, _label text) OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: reset_events_serials(); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.reset_events_serials() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM setval('events_timed_id_seq', (SELECT max(id)+1 FROM events_timed));
|
||||
PERFORM setval('events_seq_id_seq', (SELECT max(id)+1 FROM events_seq));
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.reset_events_serials() OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: to_binning_grid(public.geometry); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry) RETURNS public.geometry
|
||||
LANGUAGE plpgsql STABLE LEAKPROOF
|
||||
AS $$DECLARE
|
||||
bp jsonb := binning_parameters();
|
||||
theta numeric := (bp->>'theta')::numeric * pi() / 180;
|
||||
I_inc numeric DEFAULT 1;
|
||||
J_inc numeric DEFAULT 1;
|
||||
I_width numeric := (bp->>'I_width')::numeric;
|
||||
J_width numeric := (bp->>'J_width')::numeric;
|
||||
|
||||
a numeric := (I_inc/I_width) * cos(theta);
|
||||
b numeric := (I_inc/I_width) * -sin(theta);
|
||||
c numeric := (J_inc/J_width) * sin(theta);
|
||||
d numeric := (J_inc/J_width) * cos(theta);
|
||||
xoff numeric := (bp->'origin'->>'I')::numeric;
|
||||
yoff numeric := (bp->'origin'->>'J')::numeric;
|
||||
E0 numeric := (bp->'origin'->>'easting')::numeric;
|
||||
N0 numeric := (bp->'origin'->>'northing')::numeric;
|
||||
BEGIN
|
||||
-- RAISE NOTICE 'Matrix: a: %, b: %, c: %, d: %, xoff: %, yoff: %', a, b, c, d, xoff, yoff;
|
||||
RETURN ST_SetSRID(ST_Affine(ST_Translate(geom, -E0, -N0), a, b, c, d, xoff, yoff), 0);
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry) OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: to_binning_grid(public.geometry, jsonb); Type: FUNCTION; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry, bp jsonb) RETURNS public.geometry
|
||||
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
||||
AS $$DECLARE
|
||||
-- bp jsonb := binning_parameters();
|
||||
theta numeric := (bp->>'theta')::numeric * pi() / 180;
|
||||
I_inc numeric DEFAULT 1;
|
||||
J_inc numeric DEFAULT 1;
|
||||
I_width numeric := (bp->>'I_width')::numeric;
|
||||
J_width numeric := (bp->>'J_width')::numeric;
|
||||
|
||||
a numeric := (I_inc/I_width) * cos(theta);
|
||||
b numeric := (I_inc/I_width) * -sin(theta);
|
||||
c numeric := (J_inc/J_width) * sin(theta);
|
||||
d numeric := (J_inc/J_width) * cos(theta);
|
||||
xoff numeric := (bp->'origin'->>'I')::numeric;
|
||||
yoff numeric := (bp->'origin'->>'J')::numeric;
|
||||
E0 numeric := (bp->'origin'->>'easting')::numeric;
|
||||
N0 numeric := (bp->'origin'->>'northing')::numeric;
|
||||
BEGIN
|
||||
-- RAISE NOTICE 'Matrix: a: %, b: %, c: %, d: %, xoff: %, yoff: %', a, b, c, d, xoff, yoff;
|
||||
RETURN ST_SetSRID(ST_Affine(ST_Translate(geom, -E0, -N0), a, b, c, d, xoff, yoff), 0);
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION _SURVEY__TEMPLATE_.to_binning_grid(geom public.geometry, bp jsonb) OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: events_labels; Type: VIEW; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
@@ -824,7 +1151,8 @@ CREATE VIEW _SURVEY__TEMPLATE_.final_lines_summary AS
|
||||
WHERE ((preplot_points.line = fl.line) AND (((preplot_points.point >= s.fsp) AND (preplot_points.point <= s.lsp)) OR ((preplot_points.point >= s.lsp) AND (preplot_points.point <= s.fsp))))) - s.num_points) AS missing_shots,
|
||||
s.length,
|
||||
s.azimuth,
|
||||
fl.remarks
|
||||
fl.remarks,
|
||||
fl.meta
|
||||
FROM (summary s
|
||||
JOIN _SURVEY__TEMPLATE_.final_lines fl USING (sequence));
|
||||
|
||||
@@ -1384,7 +1712,8 @@ CREATE VIEW _SURVEY__TEMPLATE_.raw_lines_summary AS
|
||||
s.length,
|
||||
s.azimuth,
|
||||
rl.remarks,
|
||||
rl.ntbp
|
||||
rl.ntbp,
|
||||
rl.meta
|
||||
FROM (summary s
|
||||
JOIN _SURVEY__TEMPLATE_.raw_lines rl USING (sequence));
|
||||
|
||||
@@ -1530,6 +1859,8 @@ CREATE VIEW _SURVEY__TEMPLATE_.sequences_summary AS
|
||||
COALESCE(fls.azimuth, rls.azimuth) AS azimuth,
|
||||
rls.remarks,
|
||||
fls.remarks AS remarks_final,
|
||||
rls.meta,
|
||||
fls.meta AS meta_final,
|
||||
CASE
|
||||
WHEN (rls.ntbp IS TRUE) THEN 'ntbp'::text
|
||||
WHEN (fls.sequence IS NULL) THEN 'raw'::text
|
||||
@@ -1555,6 +1886,14 @@ ALTER TABLE ONLY _SURVEY__TEMPLATE_.events_seq ALTER COLUMN id SET DEFAULT nextv
|
||||
ALTER TABLE ONLY _SURVEY__TEMPLATE_.events_timed ALTER COLUMN id SET DEFAULT nextval('_SURVEY__TEMPLATE_.events_timed_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: events_seq_labels events_seq_labels_pkey; Type: CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY _SURVEY__TEMPLATE_.events_seq_labels
|
||||
ADD CONSTRAINT events_seq_labels_pkey PRIMARY KEY (id, label);
|
||||
|
||||
|
||||
--
|
||||
-- Name: events_seq events_seq_pkey; Type: CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
@@ -1656,7 +1995,7 @@ ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
|
||||
ADD CONSTRAINT planned_lines_pkey PRIMARY KEY (sequence);
|
||||
ADD CONSTRAINT planned_lines_pkey PRIMARY KEY (sequence) DEFERRABLE;
|
||||
|
||||
|
||||
--
|
||||
@@ -1713,6 +2052,20 @@ CREATE INDEX events_seq_sequence_idx ON _SURVEY__TEMPLATE_.events_seq USING btre
|
||||
CREATE INDEX events_timed_ts0_idx ON _SURVEY__TEMPLATE_.events_timed USING btree (tstamp);
|
||||
|
||||
|
||||
--
|
||||
-- Name: events_seq_labels events_seq_labels_single_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TRIGGER events_seq_labels_single_tg AFTER INSERT OR UPDATE ON _SURVEY__TEMPLATE_.events_seq_labels FOR EACH ROW EXECUTE FUNCTION _SURVEY__TEMPLATE_.events_seq_labels_single();
|
||||
|
||||
|
||||
--
|
||||
-- Name: events_timed_labels events_seq_labels_single_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TRIGGER events_seq_labels_single_tg AFTER INSERT OR UPDATE ON _SURVEY__TEMPLATE_.events_timed_labels FOR EACH ROW EXECUTE FUNCTION _SURVEY__TEMPLATE_.events_seq_labels_single();
|
||||
|
||||
|
||||
--
|
||||
-- Name: events_seq events_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
@@ -1762,11 +2115,18 @@ CREATE TRIGGER final_shots_qc_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TE
|
||||
CREATE TRIGGER final_shots_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.final_shots FOR EACH STATEMENT EXECUTE FUNCTION public.notify('final_shots');
|
||||
|
||||
|
||||
--
|
||||
-- Name: info info_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TRIGGER info_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.info FOR EACH ROW EXECUTE FUNCTION public.notify('info');
|
||||
|
||||
|
||||
--
|
||||
-- Name: planned_lines planned_lines_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TRIGGER planned_lines_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.planned_lines FOR EACH STATEMENT EXECUTE FUNCTION public.notify('planned_lines');
|
||||
CREATE TRIGGER planned_lines_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.planned_lines FOR EACH ROW EXECUTE FUNCTION public.notify('planned_lines');
|
||||
|
||||
|
||||
--
|
||||
|
||||
22
etc/db/upgrades/upgrade01-78adb2be→7917eeeb.sql
Normal file
22
etc/db/upgrades/upgrade01-78adb2be→7917eeeb.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Upgrade the database from commit 78adb2be to 7917eeeb.
|
||||
--
|
||||
-- This upgrade affects the `public` schema only.
|
||||
--
|
||||
-- It creates a new table, `info`, for storing arbitrary JSON
|
||||
-- data not belonging to a specific project. Currently used
|
||||
-- for the equipment list, it could also serve to store user
|
||||
-- details, configuration settings, system state, etc.
|
||||
--
|
||||
-- To apply, run as the dougal user:
|
||||
--
|
||||
-- psql < $THIS_FILE
|
||||
--
|
||||
-- NOTE: It will fail harmlessly if applied twice.
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.info (
|
||||
key text NOT NULL primary key,
|
||||
value jsonb
|
||||
);
|
||||
|
||||
CREATE TRIGGER info_tg AFTER INSERT OR DELETE OR UPDATE ON public.info FOR EACH ROW EXECUTE FUNCTION public.notify('info');
|
||||
160
etc/db/upgrades/upgrade02-6e7ba82e→53f71f70.sql
Normal file
160
etc/db/upgrades/upgrade02-6e7ba82e→53f71f70.sql
Normal file
@@ -0,0 +1,160 @@
|
||||
-- Upgrade the database from commit 6e7ba82e to 53f71f70.
|
||||
--
|
||||
-- NOTE: This upgrade must be applied to every schema in the database.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This merges two changes to the database.
|
||||
-- The first one (commit 5de64e6b) modifies the `event` view to return
|
||||
-- the `meta` column of timed and sequence events.
|
||||
-- The second one (commit 53f71f70) adds a primary key constraint to
|
||||
-- events_seq_labels (there is already an equivalent constraint on
|
||||
-- events_seq_timed).
|
||||
--
|
||||
-- To apply, run as the dougal user, for every schema in the database:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- SET search_path TO survey_*,public;
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It will fail harmlessly if applied twice.
|
||||
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP VIEW events_seq_timed CASCADE; -- Brings down events too
|
||||
ALTER TABLE ONLY events_seq_labels
|
||||
ADD CONSTRAINT events_seq_labels_pkey PRIMARY KEY (id, label);
|
||||
|
||||
|
||||
CREATE OR REPLACE VIEW events_seq_timed AS
|
||||
SELECT s.sequence,
|
||||
s.point,
|
||||
s.id,
|
||||
s.remarks,
|
||||
rs.line,
|
||||
rs.objref,
|
||||
rs.tstamp,
|
||||
rs.hash,
|
||||
s.meta,
|
||||
rs.geometry
|
||||
FROM (events_seq s
|
||||
LEFT JOIN raw_shots rs USING (sequence, point));
|
||||
|
||||
|
||||
|
||||
CREATE OR REPLACE VIEW events AS
|
||||
WITH qc AS (
|
||||
SELECT rs.sequence,
|
||||
rs.point,
|
||||
ARRAY[jsonb_array_elements_text(q.labels)] AS labels
|
||||
FROM raw_shots rs,
|
||||
LATERAL jsonb_path_query(rs.meta, '$."qc".*."labels"'::jsonpath) q(labels)
|
||||
)
|
||||
SELECT 'sequence'::text AS type,
|
||||
false AS virtual,
|
||||
s.sequence,
|
||||
s.point,
|
||||
s.id,
|
||||
s.remarks,
|
||||
s.line,
|
||||
s.objref,
|
||||
s.tstamp,
|
||||
s.hash,
|
||||
s.meta,
|
||||
(public.st_asgeojson(public.st_transform(s.geometry, 4326)))::jsonb AS geometry,
|
||||
ARRAY( SELECT esl.label
|
||||
FROM events_seq_labels esl
|
||||
WHERE (esl.id = s.id)) AS labels
|
||||
FROM events_seq_timed s
|
||||
UNION
|
||||
SELECT 'timed'::text AS type,
|
||||
false AS virtual,
|
||||
rs.sequence,
|
||||
rs.point,
|
||||
t.id,
|
||||
t.remarks,
|
||||
rs.line,
|
||||
rs.objref,
|
||||
t.tstamp,
|
||||
rs.hash,
|
||||
t.meta,
|
||||
(t.meta -> 'geometry'::text) AS geometry,
|
||||
ARRAY( SELECT etl.label
|
||||
FROM events_timed_labels etl
|
||||
WHERE (etl.id = t.id)) AS labels
|
||||
FROM ((events_timed t
|
||||
LEFT JOIN events_timed_seq ts USING (id))
|
||||
LEFT JOIN raw_shots rs USING (sequence, point))
|
||||
UNION
|
||||
SELECT 'midnight shot'::text AS type,
|
||||
true AS virtual,
|
||||
v1.sequence,
|
||||
v1.point,
|
||||
((v1.sequence * 100000) + v1.point) AS id,
|
||||
''::text AS remarks,
|
||||
v1.line,
|
||||
v1.objref,
|
||||
v1.tstamp,
|
||||
v1.hash,
|
||||
'{}'::jsonb meta,
|
||||
(public.st_asgeojson(public.st_transform(v1.geometry, 4326)))::jsonb AS geometry,
|
||||
ARRAY[v1.label] AS labels
|
||||
FROM events_midnight_shot v1
|
||||
UNION
|
||||
SELECT 'qc'::text AS type,
|
||||
true AS virtual,
|
||||
rs.sequence,
|
||||
rs.point,
|
||||
((10000000 + (rs.sequence * 100000)) + rs.point) AS id,
|
||||
(q.remarks)::text AS remarks,
|
||||
rs.line,
|
||||
rs.objref,
|
||||
rs.tstamp,
|
||||
rs.hash,
|
||||
'{}'::jsonb meta,
|
||||
(public.st_asgeojson(public.st_transform(rs.geometry, 4326)))::jsonb AS geometry,
|
||||
('{QC}'::text[] || qc.labels) AS labels
|
||||
FROM (raw_shots rs
|
||||
LEFT JOIN qc USING (sequence, point)),
|
||||
LATERAL jsonb_path_query(rs.meta, '$."qc".*."results"'::jsonpath) q(remarks)
|
||||
WHERE (rs.meta ? 'qc'::text);
|
||||
|
||||
|
||||
CREATE OR REPLACE VIEW final_lines_summary AS
|
||||
WITH summary AS (
|
||||
SELECT DISTINCT fs.sequence,
|
||||
first_value(fs.point) OVER w AS fsp,
|
||||
last_value(fs.point) OVER w AS lsp,
|
||||
first_value(fs.tstamp) OVER w AS ts0,
|
||||
last_value(fs.tstamp) OVER w AS ts1,
|
||||
count(fs.point) OVER w AS num_points,
|
||||
public.st_distance(first_value(fs.geometry) OVER w, last_value(fs.geometry) OVER w) AS length,
|
||||
((public.st_azimuth(first_value(fs.geometry) OVER w, last_value(fs.geometry) OVER w) * (180)::double precision) / pi()) AS azimuth
|
||||
FROM final_shots fs
|
||||
WINDOW w AS (PARTITION BY fs.sequence ORDER BY fs.tstamp ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
|
||||
)
|
||||
SELECT fl.sequence,
|
||||
fl.line,
|
||||
s.fsp,
|
||||
s.lsp,
|
||||
s.ts0,
|
||||
s.ts1,
|
||||
(s.ts1 - s.ts0) AS duration,
|
||||
s.num_points,
|
||||
(( SELECT count(*) AS count
|
||||
FROM preplot_points
|
||||
WHERE ((preplot_points.line = fl.line) AND (((preplot_points.point >= s.fsp) AND (preplot_points.point <= s.lsp)) OR ((preplot_points.point >= s.lsp) AND (preplot_points.point <= s.fsp))))) - s.num_points) AS missing_shots,
|
||||
s.length,
|
||||
s.azimuth,
|
||||
fl.remarks,
|
||||
fl.meta
|
||||
FROM (summary s
|
||||
JOIN final_lines fl USING (sequence));
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
|
||||
171
etc/db/upgrades/upgrade03-53f71f70→4d977848.sql
Normal file
171
etc/db/upgrades/upgrade03-53f71f70→4d977848.sql
Normal file
@@ -0,0 +1,171 @@
|
||||
-- Upgrade the database from commit 53f71f70 to 4d977848.
|
||||
--
|
||||
-- NOTE: This upgrade must be applied to every schema in the database.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This adds:
|
||||
--
|
||||
-- * label_in_sequence (_sequence integer, _label text):
|
||||
-- Returns events containing the specified label.
|
||||
--
|
||||
-- * handle_final_line_events (_seq integer, _label text, _column text):
|
||||
-- - If _label does not exist in the events for sequence _seq:
|
||||
-- it adds a new _label label at the shotpoint obtained from
|
||||
-- final_lines_summary[_column].
|
||||
-- - If _label does exist (and hasn't been auto-added by this function
|
||||
-- in a previous run), it will add information about it to the final
|
||||
-- line's metadata.
|
||||
--
|
||||
-- * final_line_post_import (_seq integer):
|
||||
-- Calls handle_final_line_events() on the given sequence to check
|
||||
-- for FSP, FGSP, LGSP and LSP labels.
|
||||
--
|
||||
-- * events_seq_labels_single ():
|
||||
-- Trigger function to ensure that labels that have the attribute
|
||||
-- `model.multiple` set to `false` occur at most only once per
|
||||
-- sequence. If a new instance is added to a sequence, the previous
|
||||
-- instance is deleted.
|
||||
--
|
||||
-- * Trigger on events_seq_labels that calls events_seq_labels_single().
|
||||
--
|
||||
-- * Trigger on events_timed_labels that calls events_seq_labels_single().
|
||||
--
|
||||
-- To apply, run as the dougal user, for every schema in the database:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- SET search_path TO survey_*,public;
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It will fail harmlessly if applied twice.
|
||||
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION label_in_sequence (_sequence integer, _label text)
|
||||
RETURNS events
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
SELECT * FROM events WHERE sequence = _sequence AND _label = ANY(labels);
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE handle_final_line_events (_seq integer, _label text, _column text)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
|
||||
DECLARE
|
||||
_line final_lines_summary%ROWTYPE;
|
||||
_column_value integer;
|
||||
_tg_name text := 'final_line';
|
||||
_event events%ROWTYPE;
|
||||
event_id integer;
|
||||
BEGIN
|
||||
|
||||
SELECT * INTO _line FROM final_lines_summary WHERE sequence = _seq;
|
||||
_event := label_in_sequence(_seq, _label);
|
||||
_column_value := row_to_json(_line)->>_column;
|
||||
|
||||
--RAISE NOTICE '% is %', _label, _event;
|
||||
--RAISE NOTICE 'Line is %', _line;
|
||||
--RAISE NOTICE '% is % (%)', _column, _column_value, _label;
|
||||
|
||||
IF _event IS NULL THEN
|
||||
--RAISE NOTICE 'We will populate the event log from the sequence data';
|
||||
|
||||
SELECT id INTO event_id FROM events_seq WHERE sequence = _seq AND point = _column_value ORDER BY id LIMIT 1;
|
||||
IF event_id IS NULL THEN
|
||||
--RAISE NOTICE '… but there is no existing event so we create a new one for sequence % and point %', _line.sequence, _column_value;
|
||||
INSERT INTO events_seq (sequence, point, remarks)
|
||||
VALUES (_line.sequence, _column_value, format('%s %s', _label, (SELECT meta->>'lineName' FROM final_lines WHERE sequence = _seq)))
|
||||
RETURNING id INTO event_id;
|
||||
--RAISE NOTICE 'Created event_id %', event_id;
|
||||
END IF;
|
||||
|
||||
--RAISE NOTICE 'Remove any other auto-inserted % labels in sequence %', _label, _seq;
|
||||
DELETE FROM events_seq_labels
|
||||
WHERE label = _label AND id = (SELECT id FROM events_seq WHERE sequence = _seq AND meta->'auto' ? _label);
|
||||
|
||||
--RAISE NOTICE 'We now add a label to the event (id, label) = (%, %)', event_id, _label;
|
||||
INSERT INTO events_seq_labels (id, label) VALUES (event_id, _label) ON CONFLICT ON CONSTRAINT events_seq_labels_pkey DO NOTHING;
|
||||
|
||||
--RAISE NOTICE 'And also clear the %: % flag from meta.auto for any existing events for sequence %', _label, _tg_name, _seq;
|
||||
UPDATE events_seq
|
||||
SET meta = meta #- ARRAY['auto', _label]
|
||||
WHERE meta->'auto' ? _label AND sequence = _seq AND id <> event_id;
|
||||
|
||||
--RAISE NOTICE 'Finally, flag the event as having been had label % auto-created by %', _label, _tg_name;
|
||||
UPDATE events_seq
|
||||
SET meta = jsonb_set(jsonb_set(meta, '{auto}', COALESCE(meta->'auto', '{}')), ARRAY['auto', _label], to_jsonb(_tg_name))
|
||||
WHERE id = event_id;
|
||||
|
||||
ELSE
|
||||
--RAISE NOTICE 'We may populate the sequence meta from the event log';
|
||||
--RAISE NOTICE 'Unless the event log was populated by us previously';
|
||||
--RAISE NOTICE 'Populated by us previously? %', _event.meta->'auto'->>_label = _tg_name;
|
||||
|
||||
IF _event.meta->'auto'->>_label IS DISTINCT FROM _tg_name THEN
|
||||
--RAISE NOTICE 'Adding % found in events log to final_line meta', _label;
|
||||
UPDATE final_lines
|
||||
SET meta = jsonb_set(meta, ARRAY[_label], to_jsonb(_event.point))
|
||||
WHERE sequence = _seq;
|
||||
|
||||
--RAISE NOTICE 'Clearing the %: % flag from meta.auto for any existing events in sequence %', _label, _tg_name, _seq;
|
||||
UPDATE events_seq
|
||||
SET meta = meta #- ARRAY['auto', _label]
|
||||
WHERE sequence = _seq AND meta->'auto'->>_label = _tg_name;
|
||||
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE final_line_post_import (_seq integer)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
|
||||
CALL handle_final_line_events(_seq, 'FSP', 'fsp');
|
||||
CALL handle_final_line_events(_seq, 'FGSP', 'fsp');
|
||||
CALL handle_final_line_events(_seq, 'LGSP', 'lsp');
|
||||
CALL handle_final_line_events(_seq, 'LSP', 'lsp');
|
||||
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION events_seq_labels_single ()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE _sequence integer;
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM labels WHERE name = NEW.label AND (data->'model'->'multiple')::boolean IS FALSE) THEN
|
||||
SELECT sequence INTO _sequence FROM events WHERE id = NEW.id;
|
||||
DELETE
|
||||
FROM events_seq_labels
|
||||
WHERE
|
||||
id <> NEW.id
|
||||
AND label = NEW.label
|
||||
AND id IN (SELECT id FROM events_seq WHERE sequence = _sequence);
|
||||
|
||||
DELETE
|
||||
FROM events_timed_labels
|
||||
WHERE
|
||||
id <> NEW.id
|
||||
AND label = NEW.label
|
||||
AND id IN (SELECT id FROM events_timed_seq WHERE sequence = _sequence);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER events_seq_labels_single_tg AFTER INSERT OR UPDATE ON events_seq_labels FOR EACH ROW EXECUTE FUNCTION events_seq_labels_single();
|
||||
CREATE TRIGGER events_seq_labels_single_tg AFTER INSERT OR UPDATE ON events_timed_labels FOR EACH ROW EXECUTE FUNCTION events_seq_labels_single();
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
|
||||
94
etc/db/upgrades/upgrade04-4d977848→3d70a460.sql
Normal file
94
etc/db/upgrades/upgrade04-4d977848→3d70a460.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Upgrade the database from commit 4d977848 to 3d70a460.
|
||||
--
|
||||
-- NOTE: This upgrade must be applied to every schema in the database.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This adds the `meta` column to the output of the following views:
|
||||
--
|
||||
-- * raw_lines_summary; and
|
||||
-- * sequences_summary
|
||||
--
|
||||
-- To apply, run as the dougal user, for every schema in the database:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- SET search_path TO survey_*,public;
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It can be applied multiple times without ill effect.
|
||||
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE VIEW raw_lines_summary AS
|
||||
WITH summary AS (
|
||||
SELECT DISTINCT rs.sequence,
|
||||
first_value(rs.point) OVER w AS fsp,
|
||||
last_value(rs.point) OVER w AS lsp,
|
||||
first_value(rs.tstamp) OVER w AS ts0,
|
||||
last_value(rs.tstamp) OVER w AS ts1,
|
||||
count(rs.point) OVER w AS num_points,
|
||||
count(pp.point) OVER w AS num_preplots,
|
||||
public.st_distance(first_value(rs.geometry) OVER w, last_value(rs.geometry) OVER w) AS length,
|
||||
((public.st_azimuth(first_value(rs.geometry) OVER w, last_value(rs.geometry) OVER w) * (180)::double precision) / pi()) AS azimuth
|
||||
FROM (raw_shots rs
|
||||
LEFT JOIN preplot_points pp USING (line, point))
|
||||
WINDOW w AS (PARTITION BY rs.sequence ORDER BY rs.tstamp ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
|
||||
)
|
||||
SELECT rl.sequence,
|
||||
rl.line,
|
||||
s.fsp,
|
||||
s.lsp,
|
||||
s.ts0,
|
||||
s.ts1,
|
||||
(s.ts1 - s.ts0) AS duration,
|
||||
s.num_points,
|
||||
s.num_preplots,
|
||||
(( SELECT count(*) AS count
|
||||
FROM preplot_points
|
||||
WHERE ((preplot_points.line = rl.line) AND (((preplot_points.point >= s.fsp) AND (preplot_points.point <= s.lsp)) OR ((preplot_points.point >= s.lsp) AND (preplot_points.point <= s.fsp))))) - s.num_preplots) AS missing_shots,
|
||||
s.length,
|
||||
s.azimuth,
|
||||
rl.remarks,
|
||||
rl.ntbp,
|
||||
rl.meta
|
||||
FROM (summary s
|
||||
JOIN raw_lines rl USING (sequence));
|
||||
|
||||
DROP VIEW sequences_summary;
|
||||
CREATE OR REPLACE VIEW sequences_summary AS
|
||||
SELECT rls.sequence,
|
||||
rls.line,
|
||||
rls.fsp,
|
||||
rls.lsp,
|
||||
fls.fsp AS fsp_final,
|
||||
fls.lsp AS lsp_final,
|
||||
rls.ts0,
|
||||
rls.ts1,
|
||||
fls.ts0 AS ts0_final,
|
||||
fls.ts1 AS ts1_final,
|
||||
rls.duration,
|
||||
fls.duration AS duration_final,
|
||||
rls.num_preplots,
|
||||
COALESCE(fls.num_points, rls.num_points) AS num_points,
|
||||
COALESCE(fls.missing_shots, rls.missing_shots) AS missing_shots,
|
||||
COALESCE(fls.length, rls.length) AS length,
|
||||
COALESCE(fls.azimuth, rls.azimuth) AS azimuth,
|
||||
rls.remarks,
|
||||
fls.remarks AS remarks_final,
|
||||
rls.meta,
|
||||
fls.meta AS meta_final,
|
||||
CASE
|
||||
WHEN (rls.ntbp IS TRUE) THEN 'ntbp'::text
|
||||
WHEN (fls.sequence IS NULL) THEN 'raw'::text
|
||||
ELSE 'final'::text
|
||||
END AS status
|
||||
FROM (raw_lines_summary rls
|
||||
LEFT JOIN final_lines_summary fls USING (sequence));
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
|
||||
33
etc/db/upgrades/upgrade05-3d70a460→0983abac.sql
Normal file
33
etc/db/upgrades/upgrade05-3d70a460→0983abac.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Upgrade the database from commit 3d70a460 to 0983abac.
|
||||
--
|
||||
-- NOTE: This upgrade must be applied to every schema in the database.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This:
|
||||
--
|
||||
-- * makes the primary key on planned_lines deferrable; and
|
||||
-- * changes the planned_lines trigger from statement to row.
|
||||
--
|
||||
-- To apply, run as the dougal user, for every schema in the database:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- SET search_path TO survey_*,public;
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It can be applied multiple times without ill effect.
|
||||
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE planned_lines DROP CONSTRAINT planned_lines_pkey;
|
||||
ALTER TABLE planned_lines ADD CONSTRAINT planned_lines_pkey PRIMARY KEY (sequence) DEFERRABLE;
|
||||
|
||||
DROP TRIGGER planned_lines_tg ON planned_lines;
|
||||
CREATE TRIGGER planned_lines_tg AFTER INSERT OR DELETE OR UPDATE ON planned_lines FOR EACH ROW EXECUTE FUNCTION public.notify('planned_lines');
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
207
etc/db/upgrades/upgrade06-0983abac→81d9ea19.sql
Normal file
207
etc/db/upgrades/upgrade06-0983abac→81d9ea19.sql
Normal file
@@ -0,0 +1,207 @@
|
||||
-- Upgrade the database from commit 0983abac to 81d9ea19.
|
||||
--
|
||||
-- NOTE: This upgrade must be applied to every schema in the database.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This defines a new procedure adjust_planner() which resolves some
|
||||
-- conflicts between shot sequences and the planner, such as removing
|
||||
-- sequences that have been shot, renumbering, or adjusting the planned
|
||||
-- times.
|
||||
--
|
||||
-- It is meant to be called at regular intervals by an external process,
|
||||
-- such as the runner (software/bin/runner.sh).
|
||||
--
|
||||
-- A trigger for changes to the schema's `info` table is also added.
|
||||
--
|
||||
-- To apply, run as the dougal user, for every schema in the database:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- SET search_path TO survey_*,public;
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It can be applied multiple times without ill effect.
|
||||
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE adjust_planner ()
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
_planner_config jsonb;
|
||||
_planned_line planned_lines%ROWTYPE;
|
||||
_lag interval;
|
||||
_last_sequence sequences_summary%ROWTYPE;
|
||||
_deltatime interval;
|
||||
_shotinterval interval;
|
||||
_tstamp timestamptz;
|
||||
_incr integer;
|
||||
BEGIN
|
||||
|
||||
SET CONSTRAINTS planned_lines_pkey DEFERRED;
|
||||
|
||||
SELECT data->'planner'
|
||||
INTO _planner_config
|
||||
FROM file_data
|
||||
WHERE data ? 'planner';
|
||||
|
||||
SELECT *
|
||||
INTO _last_sequence
|
||||
FROM sequences_summary
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 1;
|
||||
|
||||
SELECT *
|
||||
INTO _planned_line
|
||||
FROM planned_lines
|
||||
WHERE sequence = _last_sequence.sequence AND line = _last_sequence.line;
|
||||
|
||||
SELECT
|
||||
COALESCE(
|
||||
((lead(ts0) OVER (ORDER BY sequence)) - ts1),
|
||||
make_interval(mins => (_planner_config->>'defaultLineChangeDuration')::integer)
|
||||
)
|
||||
INTO _lag
|
||||
FROM planned_lines
|
||||
WHERE sequence = _last_sequence.sequence AND line = _last_sequence.line;
|
||||
|
||||
_incr = sign(_last_sequence.lsp - _last_sequence.fsp);
|
||||
|
||||
RAISE NOTICE '_planner_config: %', _planner_config;
|
||||
RAISE NOTICE '_last_sequence: %', _last_sequence;
|
||||
RAISE NOTICE '_planned_line: %', _planned_line;
|
||||
RAISE NOTICE '_incr: %', _incr;
|
||||
|
||||
-- Does the latest sequence match a planned sequence?
|
||||
IF _planned_line IS NULL THEN -- No it doesn't
|
||||
RAISE NOTICE 'Latest sequence shot does not match a planned sequence';
|
||||
SELECT * INTO _planned_line FROM planned_lines ORDER BY sequence ASC LIMIT 1;
|
||||
RAISE NOTICE '_planned_line: %', _planned_line;
|
||||
|
||||
IF _planned_line.sequence <= _last_sequence.sequence THEN
|
||||
RAISE NOTICE 'Renumbering the planned sequences starting from %', _planned_line.sequence + 1;
|
||||
-- Renumber the planned sequences starting from last shot sequence number + 1
|
||||
UPDATE planned_lines
|
||||
SET sequence = sequence + _last_sequence.sequence - _planned_line.sequence + 1;
|
||||
END IF;
|
||||
|
||||
-- The correction to make to the first planned line's ts0 will be based on either the last
|
||||
-- sequence's EOL + default line change time or the current time, whichever is later.
|
||||
_deltatime := GREATEST(COALESCE(_last_sequence.ts1_final, _last_sequence.ts1) + make_interval(mins => (_planner_config->>'defaultLineChangeDuration')::integer), current_timestamp) - _planned_line.ts0;
|
||||
|
||||
-- Is the first of the planned lines start time in the past? (±5 mins)
|
||||
IF _planned_line.ts0 < (current_timestamp - make_interval(mins => 5)) THEN
|
||||
RAISE NOTICE 'First planned line is in the past. Adjusting times by %', _deltatime;
|
||||
-- Adjust the start / end time of the planned lines by assuming that we are at
|
||||
-- `defaultLineChangeDuration` minutes away from SOL of the first planned line.
|
||||
UPDATE planned_lines
|
||||
SET
|
||||
ts0 = ts0 + _deltatime,
|
||||
ts1 = ts1 + _deltatime;
|
||||
END IF;
|
||||
|
||||
ELSE -- Yes it does
|
||||
RAISE NOTICE 'Latest sequence does match a planned sequence: %, %', _planned_line.sequence, _planned_line.line;
|
||||
|
||||
-- Is it online?
|
||||
IF EXISTS(SELECT 1 FROM raw_lines_files WHERE sequence = _last_sequence.sequence AND hash = '*online*') THEN
|
||||
-- Yes it is
|
||||
RAISE NOTICE 'Sequence % is online', _last_sequence.sequence;
|
||||
|
||||
-- Let us get the SOL from the events log if we can
|
||||
RAISE NOTICE 'Trying to set fsp, ts0 from events log FSP, FGSP';
|
||||
WITH e AS (
|
||||
SELECT * FROM events
|
||||
WHERE
|
||||
sequence = _last_sequence.sequence
|
||||
AND ('FSP' = ANY(labels) OR 'FGSP' = ANY(labels))
|
||||
ORDER BY tstamp LIMIT 1
|
||||
)
|
||||
UPDATE planned_lines
|
||||
SET
|
||||
fsp = COALESCE(e.point, fsp),
|
||||
ts0 = COALESCE(e.tstamp, ts0)
|
||||
FROM e
|
||||
WHERE planned_lines.sequence = _last_sequence.sequence;
|
||||
|
||||
-- Shot interval
|
||||
_shotinterval := (_last_sequence.ts1 - _last_sequence.ts0) / abs(_last_sequence.lsp - _last_sequence.fsp);
|
||||
|
||||
RAISE NOTICE 'Estimating EOL from current shot interval: %', _shotinterval;
|
||||
|
||||
SELECT (abs(lsp-fsp) * _shotinterval + ts0) - ts1
|
||||
INTO _deltatime
|
||||
FROM planned_lines
|
||||
WHERE sequence = _last_sequence.sequence;
|
||||
|
||||
---- Set ts1 for the current sequence
|
||||
--UPDATE planned_lines
|
||||
--SET
|
||||
--ts1 = (abs(lsp-fsp) * _shotinterval) + ts0
|
||||
--WHERE sequence = _last_sequence.sequence;
|
||||
|
||||
RAISE NOTICE 'Adjustment is %', _deltatime;
|
||||
|
||||
IF abs(EXTRACT(EPOCH FROM _deltatime)) < 8 THEN
|
||||
RAISE NOTICE 'Adjustment too small (< 8 s), so not applying it';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Adjust ts1 for the current sequence
|
||||
UPDATE planned_lines
|
||||
SET ts1 = ts1 + _deltatime
|
||||
WHERE sequence = _last_sequence.sequence;
|
||||
|
||||
-- Now shift all sequences after
|
||||
UPDATE planned_lines
|
||||
SET ts0 = ts0 + _deltatime, ts1 = ts1 + _deltatime
|
||||
WHERE sequence > _last_sequence.sequence;
|
||||
|
||||
RAISE NOTICE 'Deleting planned sequences before %', _planned_line.sequence;
|
||||
-- Remove all previous planner entries.
|
||||
DELETE
|
||||
FROM planned_lines
|
||||
WHERE sequence < _last_sequence.sequence;
|
||||
|
||||
ELSE
|
||||
-- No it isn't
|
||||
RAISE NOTICE 'Sequence % is offline', _last_sequence.sequence;
|
||||
|
||||
-- We were supposed to finish at _planned_line.ts1 but we finished at:
|
||||
_tstamp := GREATEST(COALESCE(_last_sequence.ts1_final, _last_sequence.ts1), current_timestamp);
|
||||
-- WARNING Next line is for testing only
|
||||
--_tstamp := COALESCE(_last_sequence.ts1_final, _last_sequence.ts1);
|
||||
-- So we need to adjust timestamps by:
|
||||
_deltatime := _tstamp - _planned_line.ts1;
|
||||
|
||||
RAISE NOTICE 'Planned end: %, actual end: % (%, %)', _planned_line.ts1, _tstamp, _planned_line.sequence, _last_sequence.sequence;
|
||||
RAISE NOTICE 'Shifting times by % for sequences > %', _deltatime, _planned_line.sequence;
|
||||
-- NOTE: This won't work if sequences are not, err… sequential.
|
||||
-- NOTE: This has been known to happen in 2020.
|
||||
UPDATE planned_lines
|
||||
SET
|
||||
ts0 = ts0 + _deltatime,
|
||||
ts1 = ts1 + _deltatime
|
||||
WHERE sequence > _planned_line.sequence;
|
||||
|
||||
RAISE NOTICE 'Deleting planned sequences up to %', _planned_line.sequence;
|
||||
-- Remove all previous planner entries.
|
||||
DELETE
|
||||
FROM planned_lines
|
||||
WHERE sequence <= _last_sequence.sequence;
|
||||
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS info_tg ON info;
|
||||
CREATE TRIGGER info_tg AFTER INSERT OR DELETE OR UPDATE ON info FOR EACH ROW EXECUTE FUNCTION public.notify('info');
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
91
etc/db/upgrades/upgrade07-81d9ea19→0a10c897.sql
Normal file
91
etc/db/upgrades/upgrade07-81d9ea19→0a10c897.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- Upgrade the database from commit 81d9ea19 to 0a10c897.
|
||||
--
|
||||
-- NOTE: This upgrade must be applied to every schema in the database.
|
||||
-- NOTE: Each application starts a transaction, which must be committed
|
||||
-- or rolled back.
|
||||
--
|
||||
-- This defines a new function ij_error(line, point, geometry) which
|
||||
-- returns the crossline and inline distance (in metres) between the
|
||||
-- geometry (which must be a point) and the preplot corresponding to
|
||||
-- line / point.
|
||||
--
|
||||
-- To apply, run as the dougal user, for every schema in the database:
|
||||
--
|
||||
-- psql <<EOF
|
||||
-- SET search_path TO survey_*,public;
|
||||
-- \i $THIS_FILE
|
||||
-- COMMIT;
|
||||
-- EOF
|
||||
--
|
||||
-- NOTE: It can be applied multiple times without ill effect.
|
||||
|
||||
|
||||
BEGIN;
|
||||
|
||||
|
||||
-- Return the crossline, inline error of `geom` with respect to `line` and `point`
|
||||
-- in the project's binning grid.
|
||||
|
||||
CREATE OR REPLACE FUNCTION ij_error(line double precision, point double precision, geom public.geometry)
|
||||
RETURNS public.geometry(Point, 0)
|
||||
LANGUAGE plpgsql STABLE LEAKPROOF
|
||||
AS $$
|
||||
DECLARE
|
||||
bp jsonb := binning_parameters();
|
||||
ij public.geometry := to_binning_grid(geom, bp);
|
||||
|
||||
theta numeric := (bp->>'theta')::numeric * pi() / 180;
|
||||
I_inc numeric DEFAULT 1;
|
||||
J_inc numeric DEFAULT 1;
|
||||
I_width numeric := (bp->>'I_width')::numeric;
|
||||
J_width numeric := (bp->>'J_width')::numeric;
|
||||
|
||||
a numeric := (I_inc/I_width) * cos(theta);
|
||||
b numeric := (I_inc/I_width) * -sin(theta);
|
||||
c numeric := (J_inc/J_width) * sin(theta);
|
||||
d numeric := (J_inc/J_width) * cos(theta);
|
||||
xoff numeric := (bp->'origin'->>'I')::numeric;
|
||||
yoff numeric := (bp->'origin'->>'J')::numeric;
|
||||
E0 numeric := (bp->'origin'->>'easting')::numeric;
|
||||
N0 numeric := (bp->'origin'->>'northing')::numeric;
|
||||
|
||||
error_i double precision;
|
||||
error_j double precision;
|
||||
BEGIN
|
||||
error_i := (public.st_x(ij) - line) * I_width;
|
||||
error_j := (public.st_y(ij) - point) * J_width;
|
||||
|
||||
RETURN public.ST_MakePoint(error_i, error_j);
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
-- Return the list of points and metadata for all sequences.
|
||||
-- Only points which have a corresponding preplot are returned.
|
||||
-- If available, final positions are returned as well, if not they
|
||||
-- are NULL.
|
||||
-- Likewise, crossline / inline errors are also returned as a PostGIS
|
||||
-- 2D point both for raw and final data.
|
||||
|
||||
CREATE OR REPLACE VIEW sequences_detail AS
|
||||
SELECT
|
||||
rl.sequence, rl.line AS sailline,
|
||||
rs.line, rs.point,
|
||||
rs.tstamp,
|
||||
rs.objref objRefRaw, fs.objref objRefFinal,
|
||||
ST_Transform(pp.geometry, 4326) geometryPreplot,
|
||||
ST_Transform(rs.geometry, 4326) geometryRaw,
|
||||
ST_Transform(fs.geometry, 4326) geometryFinal,
|
||||
ij_error(rs.line, rs.point, rs.geometry) errorRaw,
|
||||
ij_error(rs.line, rs.point, fs.geometry) errorFinal,
|
||||
json_build_object('preplot', pp.meta, 'raw', rs.meta, 'final', fs.meta) meta
|
||||
FROM
|
||||
raw_lines rl
|
||||
INNER JOIN raw_shots rs USING (sequence)
|
||||
INNER JOIN preplot_points pp ON rs.line = pp.line AND rs.point = pp.point
|
||||
LEFT JOIN final_shots fs ON rl.sequence = fs.sequence AND rs.point = fs.point;
|
||||
|
||||
|
||||
--
|
||||
--NOTE Run `COMMIT;` now if all went well
|
||||
--
|
||||
245
etc/default/templates/plan.html.njk
Normal file
245
etc/default/templates/plan.html.njk
Normal file
File diff suppressed because one or more lines are too long
333
etc/default/templates/sequence.html.njk
Executable file
333
etc/default/templates/sequence.html.njk
Executable file
File diff suppressed because one or more lines are too long
@@ -20,11 +20,20 @@
|
||||
disabled: false
|
||||
labels: [ "QC", "QCGuns" ]
|
||||
children:
|
||||
-
|
||||
name: "Sequences without gun data"
|
||||
iterate: "sequences"
|
||||
id: seq_no_gun_data
|
||||
check: |
|
||||
const sequence = currentItem;
|
||||
currentItem.has_smsrc_data || "Sequence has no gun data"
|
||||
-
|
||||
name: "Missing gun data"
|
||||
id: missing_gun_data
|
||||
check: |
|
||||
!!currentItem._("raw_meta.smsrc.guns") || "Missing gun data"
|
||||
sequences.some(s => s.sequence == currentItem.sequence && s.has_smsrc_data)
|
||||
? (!!currentItem._("raw_meta.smsrc.guns") || "Missing gun data")
|
||||
: true
|
||||
|
||||
-
|
||||
name: "No fire"
|
||||
@@ -192,7 +201,7 @@
|
||||
check: |
|
||||
const currentShot = currentItem;
|
||||
Math.abs(currentShot.error_i) <= parameters.crosslineError
|
||||
|| `Crossline error: ${currentShot.error_i.toFixed(1)} > ${parameters.crosslineError}`
|
||||
|| `Crossline error (${currentShot.type}): ${currentShot.error_i.toFixed(1)} > ${parameters.crosslineError}`
|
||||
|
||||
-
|
||||
name: "Inline"
|
||||
@@ -200,7 +209,7 @@
|
||||
check: |
|
||||
const currentShot = currentItem;
|
||||
Math.abs(currentShot.error_j) <= parameters.inlineError
|
||||
|| `Inline error: ${currentShot.error_j.toFixed(1)} > ${parameters.inlineError}`
|
||||
|| `Inline error (${currentShot.type}): ${currentShot.error_j.toFixed(1)} > ${parameters.inlineError}`
|
||||
|
||||
-
|
||||
name: "Centre of source preplot deviation (moving average)"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"jwt": {
|
||||
"secret": ""
|
||||
"secret": "",
|
||||
"options": {
|
||||
"expiresIn": 1800
|
||||
}
|
||||
},
|
||||
"db": {
|
||||
"user": "postgres",
|
||||
|
||||
14364
lib/www/client/source/package-lock.json
generated
14364
lib/www/client/source/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,23 +9,27 @@
|
||||
"dependencies": {
|
||||
"@mdi/font": "^5.6.55",
|
||||
"core-js": "^3.6.5",
|
||||
"d3": "^7.0.1",
|
||||
"jwt-decode": "^3.0.0",
|
||||
"leaflet": "^1.7.1",
|
||||
"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",
|
||||
"suncalc": "^1.8.0",
|
||||
"typeface-roboto": "0.0.75",
|
||||
"vue": "^2.6.12",
|
||||
"vue-debounce": "^2.5.7",
|
||||
"vue-router": "^3.4.5",
|
||||
"vuetify": "^2.3.12",
|
||||
"vuex": "^3.5.1"
|
||||
"vue-debounce": "^2.6.0",
|
||||
"vue-router": "^3.5.1",
|
||||
"vuetify": "^2.5.0",
|
||||
"vuex": "^3.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.4.0",
|
||||
"@vue/cli-service": "^4.5.13",
|
||||
"sass": "^1.26.11",
|
||||
"sass-loader": "^8.0.0",
|
||||
"stylus": "^0.54.8",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 210 KiB |
BIN
lib/www/client/source/public/wgp-logo.png
Normal file
BIN
lib/www/client/source/public/wgp-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -26,9 +26,16 @@
|
||||
<style lang="stylus">
|
||||
@import '../node_modules/typeface-roboto/index.css'
|
||||
@import '../node_modules/@mdi/font/css/materialdesignicons.css'
|
||||
|
||||
.markdown.v-textarea textarea
|
||||
font-family monospace
|
||||
line-height 1.1 !important
|
||||
</style>
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import DougalNavigation from './components/navigation';
|
||||
import DougalFooter from './components/footer';
|
||||
|
||||
@@ -58,12 +65,27 @@ export default {
|
||||
|
||||
snackText (newVal) {
|
||||
this.snack = !!newVal;
|
||||
},
|
||||
|
||||
snack (newVal) {
|
||||
// When the snack is hidden (one way or another), clear
|
||||
// the text so that if we receive the same message again
|
||||
// afterwards it will be shown. This way, if we get spammed
|
||||
// we're also not triggering the snack too often.
|
||||
if (!newVal) {
|
||||
this.$store.commit('setSnackText', "");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(["setCredentials"])
|
||||
},
|
||||
|
||||
mounted () {
|
||||
// Local Storage values are always strings
|
||||
this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true";
|
||||
this.setCredentials()
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
363
lib/www/client/source/src/components/graph-arrays-ij-scatter.vue
Normal file
363
lib/www/client/source/src/components/graph-arrays-ij-scatter.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<v-card style="min-height:400px;">
|
||||
<v-card-title class="headline">
|
||||
Array inline / crossline error
|
||||
<v-spacer></v-spacer>
|
||||
<v-switch v-model="scatterplot" label="Scatterplot"></v-switch>
|
||||
<v-switch class="ml-4" v-model="histogram" label="Histogram"></v-switch>
|
||||
</v-card-title>
|
||||
|
||||
<v-container fluid fill-height>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graph0"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="scatterplot">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graph1"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="histogram">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graph2"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-overlay :value="busy" absolute z-index="1">
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.graph-container {
|
||||
background-color: red;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import Plotly from 'plotly.js-dist';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import unpack from '@/lib/unpack.js';
|
||||
|
||||
export default {
|
||||
name: 'DougalGraphArraysIJScatter',
|
||||
|
||||
props: [ "data", "settings" ],
|
||||
|
||||
data () {
|
||||
return {
|
||||
graph: [],
|
||||
busy: false,
|
||||
resizeObserver: null,
|
||||
scatterplot: false,
|
||||
histogram: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
//...mapGetters(['apiUrl'])
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
data (newVal, oldVal) {
|
||||
if (newVal === null) {
|
||||
this.busy = true;
|
||||
} else {
|
||||
this.busy = false;
|
||||
this.plot();
|
||||
}
|
||||
},
|
||||
|
||||
settings () {
|
||||
for (const key in this.settings) {
|
||||
this[key] = this.settings[key];
|
||||
}
|
||||
},
|
||||
|
||||
histogram () {
|
||||
this.plot();
|
||||
this.$emit("update:settings", {[`${this.$options.name}.histogram`]: this.histogram});
|
||||
},
|
||||
|
||||
|
||||
scatterplot () {
|
||||
this.plot();
|
||||
this.$emit("update:settings", {[`${this.$options.name}.scatterplot`]: this.scatterplot});
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
plot () {
|
||||
|
||||
this.plotSeries();
|
||||
|
||||
if (this.histogram) {
|
||||
this.plotHistogram();
|
||||
}
|
||||
|
||||
if (this.scatterplot) {
|
||||
this.plotScatter();
|
||||
}
|
||||
},
|
||||
|
||||
plotSeries () {
|
||||
if (!this.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
function transform (d, idx=0, otherParams={}) {
|
||||
const errortype = d.errorfinal ? "errorfinal" : "errorraw";
|
||||
const coords = unpack(unpack(d, errortype), "coordinates");
|
||||
const x = unpack(d, "point");
|
||||
const y = unpack(coords, idx);
|
||||
const data = {
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
x,
|
||||
y,
|
||||
transforms: [{
|
||||
type: "groupby",
|
||||
groups: unpack(unpack(d, "meta"), "src_number"),
|
||||
styles: [
|
||||
{target: 1, value: {line: {color: "green"}}},
|
||||
{target: 2, value: {line: {color: "red"}}}
|
||||
]
|
||||
}],
|
||||
...otherParams
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
const data = [
|
||||
transform(this.data.items, 1, {
|
||||
xaxis: 'x',
|
||||
yaxis: 'y',
|
||||
name: 'Crossline'
|
||||
}),
|
||||
transform(this.data.items, 0, {
|
||||
xaxis: 'x',
|
||||
yaxis: 'y2',
|
||||
name: 'Inline'
|
||||
})
|
||||
];
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
title: {text: "Inline / crossline error – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
yaxis2: {
|
||||
title: "Crossline (m)",
|
||||
anchor: "y2",
|
||||
domain: [ 0.55, 1 ]
|
||||
},
|
||||
yaxis: {
|
||||
title: "Inline (m)",
|
||||
anchor: "y1",
|
||||
domain: [ 0, 0.45 ]
|
||||
},
|
||||
xaxis: {
|
||||
title: "Shotpoint",
|
||||
anchor: "x1"
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph[0] = Plotly.newPlot(this.$refs.graph0, data, layout, config);
|
||||
},
|
||||
|
||||
plotScatter () {
|
||||
|
||||
console.log("plot");
|
||||
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
console.log("Will plot sequence", this.data.meta.project, this.data.meta.sequence);
|
||||
|
||||
function transform (d) {
|
||||
const errortype = d.errorfinal ? "errorfinal" : "errorraw";
|
||||
const coords = unpack(unpack(d, errortype), "coordinates");
|
||||
const x = unpack(coords, 0);
|
||||
const y = unpack(coords, 1);
|
||||
const data = [{
|
||||
type: "scatter",
|
||||
mode: "markers",
|
||||
x,
|
||||
y,
|
||||
transforms: [{
|
||||
type: "groupby",
|
||||
groups: unpack(unpack(d, "meta"), "src_number"),
|
||||
styles: [
|
||||
{target: 1, value: {line: {color: "green"}}},
|
||||
{target: 2, value: {line: {color: "red"}}}
|
||||
]
|
||||
}]
|
||||
}];
|
||||
return data;
|
||||
}
|
||||
|
||||
const data = transform(this.data.items);
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
//title: {text: "Inline / crossline error – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
yaxis: {
|
||||
title: "Inline (m)",
|
||||
//zeroline: false
|
||||
},
|
||||
xaxis: {
|
||||
title: "Crossline (m)"
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph[1] = Plotly.newPlot(this.$refs.graph1, data, layout, config);
|
||||
},
|
||||
|
||||
plotHistogram () {
|
||||
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
|
||||
function transform (d, idx=0, otherParams={}) {
|
||||
const errortype = d.errorfinal ? "errorfinal" : "errorraw";
|
||||
const coords = unpack(unpack(d, errortype), "coordinates");
|
||||
const x = unpack(coords, idx);
|
||||
const data = {
|
||||
type: "histogram",
|
||||
histnorm: 'probability',
|
||||
x,
|
||||
transforms: [{
|
||||
type: "groupby",
|
||||
groups: unpack(unpack(d, "meta"), "src_number"),
|
||||
styles: [
|
||||
{target: 1, value: {marker: {color: "rgba(129, 199, 132, 0.9)"}}},
|
||||
{target: 2, value: {marker: {color: "rgba(229, 115, 115, 0.9)"}}}
|
||||
]
|
||||
}],
|
||||
...otherParams
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
const data = [
|
||||
transform(this.data.items, 0, {
|
||||
xaxis: 'x',
|
||||
yaxis: 'y',
|
||||
name: 'Crossline'
|
||||
}),
|
||||
transform(this.data.items, 1, {
|
||||
xaxis: 'x2',
|
||||
yaxis: 'y',
|
||||
name: 'Inline'
|
||||
})
|
||||
];
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
//title: {text: "Inline / crossline error – sequence %{meta.sequence}"},
|
||||
legend: {
|
||||
title: { text: "Array" }
|
||||
},
|
||||
xaxis: {
|
||||
title: "Crossline distance (m)",
|
||||
domain: [ 0, 0.45 ],
|
||||
anchor: 'x1'
|
||||
},
|
||||
yaxis: {
|
||||
title: "Frequency (0‒1)",
|
||||
domain: [ 0, 1 ],
|
||||
anchor: 'y1'
|
||||
},
|
||||
xaxis2: {
|
||||
title: "Inline distance (m)",
|
||||
domain: [ 0.55, 1 ],
|
||||
anchor: 'x2'
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
this.busy = false;
|
||||
console.log(data);
|
||||
console.log(layout);
|
||||
|
||||
this.graph[2] = Plotly.newPlot(this.$refs.graph2, data, layout, config);
|
||||
},
|
||||
|
||||
replot () {
|
||||
if (!this.graph.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Replotting");
|
||||
this.graph.forEach( (graph, idx) => {
|
||||
const ref = this.$refs["graph"+idx];
|
||||
Plotly.relayout(ref, {
|
||||
width: ref.clientWidth,
|
||||
height: ref.clientHeight
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
|
||||
if (this.data) {
|
||||
this.plot();
|
||||
} else {
|
||||
this.busy = true;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.replot)
|
||||
this.resizeObserver.observe(this.$refs.graph0);
|
||||
this.resizeObserver.observe(this.$refs.graph1);
|
||||
this.resizeObserver.observe(this.$refs.graph2);
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.unobserve(this.$refs.graph2);
|
||||
this.resizeObserver.unobserve(this.$refs.graph1);
|
||||
this.resizeObserver.unobserve(this.$refs.graph0);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
</script>
|
||||
364
lib/www/client/source/src/components/graph-guns-depth.vue
Normal file
364
lib/www/client/source/src/components/graph-guns-depth.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<v-card style="min-height:400px;">
|
||||
<v-card-title class="headline">
|
||||
Gun depth
|
||||
<v-spacer></v-spacer>
|
||||
<v-switch v-model="shotpoint" label="Shotpoint"></v-switch>
|
||||
<v-switch class="ml-4" v-model="violinplot" label="Violin plot"></v-switch>
|
||||
</v-card-title>
|
||||
|
||||
<v-container fluid fill-height>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphSeries"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="shotpoint">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphBar"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="violinplot">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphViolin"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-overlay :value="busy" absolute z-index="1">
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import * as d3a from 'd3-array';
|
||||
import Plotly from 'plotly.js-dist';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import unpack from '@/lib/unpack.js';
|
||||
import * as aes from '@/lib/graphs/aesthetics.js';
|
||||
|
||||
export default {
|
||||
name: 'DougalGraphGunsDepth',
|
||||
|
||||
props: [ "data", "settings" ],
|
||||
|
||||
data () {
|
||||
return {
|
||||
graph: null,
|
||||
graphHover: null,
|
||||
busy: false,
|
||||
resizeObserver: null,
|
||||
shotpoint: true,
|
||||
violinplot: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
//...mapGetters(['apiUrl'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
data (newVal, oldVal) {
|
||||
console.log("data changed");
|
||||
|
||||
if (newVal === null) {
|
||||
this.busy = true;
|
||||
} else {
|
||||
this.busy = false;
|
||||
this.plot();
|
||||
}
|
||||
},
|
||||
|
||||
settings () {
|
||||
for (const key in this.settings) {
|
||||
this[key] = this.settings[key];
|
||||
}
|
||||
},
|
||||
|
||||
shotpoint () {
|
||||
if (this.shotpoint) {
|
||||
this.replot();
|
||||
}
|
||||
this.$emit("update:settings", {[`${this.$options.name}.shotpoint`]: this.shotpoint});
|
||||
},
|
||||
|
||||
violinplot () {
|
||||
if (this.violinplot) {
|
||||
this.plotViolin();
|
||||
}
|
||||
this.$emit("update:settings", {[`${this.$options.name}.violinplot`]: this.violinplot});
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
plot () {
|
||||
this.plotSeries();
|
||||
if (this.violinplot) {
|
||||
this.plotViolin();
|
||||
}
|
||||
},
|
||||
|
||||
async plotSeries () {
|
||||
|
||||
function transformSeries (d, src_number, otherParams={}) {
|
||||
|
||||
const meta = src_number
|
||||
? unpack(d, "meta").filter( s => s.src_number == src_number )
|
||||
: unpack(d, "meta");
|
||||
const guns = unpack(meta, "guns").map(s => s.filter(g => g[2] == src_number));;
|
||||
const gunDepths = guns.map(s => s.map(g => g[10]));
|
||||
const gunDepthsSorted = gunDepths.map(s => d3a.sort(s));
|
||||
const gunsAvgDepth = gunDepths.map( (s, sidx) => d3a.mean(s) );
|
||||
|
||||
const x = src_number
|
||||
? unpack(d.filter(s => s.meta.src_number == src_number), "point")
|
||||
: unpack(d, "point");
|
||||
|
||||
const tracesGunDepths = [{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
x,
|
||||
y: gunDepthsSorted.map(s => d3a.quantileSorted(s, 0.25)),
|
||||
...aes.gunArrays[src_number || 1].min
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
fill: "tonexty",
|
||||
x,
|
||||
y: gunsAvgDepth,
|
||||
...aes.gunArrays[src_number || 1].avg
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
fill: "tonexty",
|
||||
x,
|
||||
y: gunDepthsSorted.map(s => d3a.quantileSorted(s, 0.75)),
|
||||
...aes.gunArrays[src_number || 1].max
|
||||
}];
|
||||
|
||||
const tracesGunsDepthsIndividual = {
|
||||
//name: `Array ${src_number} outliers`,
|
||||
type: "scatter",
|
||||
mode: "markers",
|
||||
marker: {size: 2 },
|
||||
hoverinfo: "skip",
|
||||
x: gunDepthsSorted.map( (s, idx) =>
|
||||
s.filter( g => g < d3a.quantileSorted(s, 0.05) || g > d3a.quantileSorted(s, 0.95))
|
||||
.map( f => Array(f.length).fill(x[idx]) ).flat()
|
||||
).flat(),
|
||||
y: gunDepthsSorted.map( (s, idx) =>
|
||||
s.filter( g => g < d3a.quantileSorted(s, 0.05) || g > d3a.quantileSorted(s, 0.95))
|
||||
).flat(),
|
||||
...aes.gunArrays[src_number || 1].out
|
||||
};
|
||||
|
||||
const data = [ ...tracesGunDepths, tracesGunsDepthsIndividual ]
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = [ ...new Set(unpack(this.data.items, "meta").map( s => s.src_number ))];
|
||||
const data = sources.map( src_number => transformSeries(this.data.items, src_number) ).flat();
|
||||
console.log("Sources", sources);
|
||||
console.log(data);
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
title: {text: "Gun depths – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
hovermode: "x",
|
||||
yaxis: {
|
||||
title: "Depth (m)",
|
||||
//zeroline: false
|
||||
},
|
||||
xaxis: {
|
||||
title: "Shotpoint",
|
||||
showspikes: true
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph = Plotly.newPlot(this.$refs.graphSeries, data, layout, config);
|
||||
this.$refs.graphSeries.on('plotly_hover', (d) => {
|
||||
const point = d.points[0].x;
|
||||
const item = this.data.items.find(s => s.point == point);
|
||||
const guns = item.meta.guns.filter( g => g[2] == item.meta.src_number );
|
||||
const gunIds = guns.map( g => "G"+g[1] );
|
||||
const depths = unpack(guns, 10);
|
||||
const data = [{
|
||||
type: "bar",
|
||||
x: gunIds,
|
||||
y: depths,
|
||||
transforms: [{
|
||||
type: "groupby",
|
||||
groups: unpack(guns, 0)
|
||||
}],
|
||||
}];
|
||||
|
||||
const layout = {
|
||||
title: {text: "Gun depths – shot %{meta.point}"},
|
||||
height: 300,
|
||||
yaxis: {
|
||||
title: "Depth (m)",
|
||||
range: [ Math.min(d3a.min(depths)-0.1, 5), Math.max(d3a.max(depths)+0.1, 7) ]
|
||||
},
|
||||
xaxis: {
|
||||
title: "Gun number",
|
||||
type: 'category'
|
||||
},
|
||||
meta: {
|
||||
point
|
||||
}
|
||||
};
|
||||
|
||||
const config = { displaylogo: false };
|
||||
|
||||
Plotly.react(this.$refs.graphBar, data, layout, config);
|
||||
});
|
||||
},
|
||||
|
||||
async plotViolin () {
|
||||
|
||||
function transformViolin (d, opts = {}) {
|
||||
|
||||
const styles = [];
|
||||
|
||||
unpack(unpack(d, "meta"), "guns").flat().forEach(i => {
|
||||
const gunId = i[1];
|
||||
const arrayId = i[2];
|
||||
if (!styles[gunId]) {
|
||||
styles[gunId] = Object.assign({target: gunId}, aes.gunArrayViolins[arrayId]);
|
||||
}
|
||||
});
|
||||
|
||||
const data = {
|
||||
type: 'violin',
|
||||
x: unpack(unpack(unpack(d, "meta"), "guns").flat(), 1), // Gun number
|
||||
y: unpack(unpack(unpack(d, "meta"), "guns").flat(), 10), // Gun depth
|
||||
points: 'none',
|
||||
box: {
|
||||
visible: true
|
||||
},
|
||||
line: {
|
||||
color: 'green',
|
||||
},
|
||||
meanline: {
|
||||
visible: true
|
||||
},
|
||||
transforms: [{
|
||||
type: 'groupby',
|
||||
groups: unpack(unpack(unpack(d, "meta"), "guns").flat(), 1),
|
||||
styles: styles.filter(i => !!i)
|
||||
}]
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
console.log("plot violin");
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
console.log("Will plot sequence", this.data.meta.project, this.data.meta.sequence);
|
||||
|
||||
const data = [ transformViolin(this.data.items) ];
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
showlegend: false,
|
||||
title: {text: "Individual gun depths – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
yaxis: {
|
||||
title: "Depth (m)",
|
||||
zeroline: false
|
||||
},
|
||||
xaxis: {
|
||||
title: "Gun number"
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph = Plotly.newPlot(this.$refs.graphViolin, data, layout, config);
|
||||
},
|
||||
|
||||
|
||||
replot () {
|
||||
if (!this.graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Replotting");
|
||||
Object.values(this.$refs).forEach( ref => {
|
||||
if (ref.data) {
|
||||
console.log("Replotting", ref, ref.clientWidth, ref.clientHeight);
|
||||
Plotly.relayout(ref, {
|
||||
width: ref.clientWidth,
|
||||
height: ref.clientHeight
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
|
||||
if (this.data) {
|
||||
this.plot();
|
||||
} else {
|
||||
this.busy = true;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.replot)
|
||||
this.resizeObserver.observe(this.$refs.graphSeries);
|
||||
this.resizeObserver.observe(this.$refs.graphViolin);
|
||||
this.resizeObserver.observe(this.$refs.graphBar);
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.unobserve(this.$refs.graphBar);
|
||||
this.resizeObserver.unobserve(this.$refs.graphViolin);
|
||||
this.resizeObserver.unobserve(this.$refs.graphSeries);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
</script>
|
||||
405
lib/www/client/source/src/components/graph-guns-heatmap.vue
Normal file
405
lib/www/client/source/src/components/graph-guns-heatmap.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<v-card style="min-height:400px;">
|
||||
<v-card-title class="headline">
|
||||
Gun details
|
||||
</v-card-title>
|
||||
|
||||
<v-container fluid fill-height>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphHeat"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-overlay :value="busy" absolute z-index="1">
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import * as d3a from 'd3-array';
|
||||
import Plotly from 'plotly.js-dist';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import unpack from '@/lib/unpack.js';
|
||||
import * as aes from '@/lib/graphs/aesthetics.js';
|
||||
|
||||
|
||||
export default {
|
||||
name: 'DougalGraphGunsDepth',
|
||||
|
||||
props: [ "data" ],
|
||||
|
||||
data () {
|
||||
return {
|
||||
graph: null,
|
||||
graphHover: null,
|
||||
busy: false,
|
||||
resizeObserver: null,
|
||||
// TODO: aspects should be a prop
|
||||
aspects: [
|
||||
"Mode", "Detect", "Autofire", "Aimpoint", "Firetime", "Delay",
|
||||
"Delta",
|
||||
"Depth", "Pressure", "Volume", "Filltime"
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
//...mapGetters(['apiUrl'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
data (newVal, oldVal) {
|
||||
console.log("data changed");
|
||||
|
||||
if (newVal === null) {
|
||||
this.busy = true;
|
||||
} else {
|
||||
this.busy = false;
|
||||
this.plot();
|
||||
}
|
||||
},
|
||||
|
||||
violinplot () {
|
||||
if (this.violinplot) {
|
||||
this.plotViolin();
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
plot () {
|
||||
this.plotHeat();
|
||||
},
|
||||
|
||||
async plotHeat () {
|
||||
|
||||
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
|
||||
function transform (data, aspects=["Depth", "Pressure"]) {
|
||||
|
||||
const facets = [
|
||||
// Mode
|
||||
{
|
||||
params: {
|
||||
name: "Mode",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{text}",
|
||||
},
|
||||
|
||||
text: [ "Off", "Auto", "Manual", "Disabled" ],
|
||||
|
||||
conversion: (gun, shot) => {
|
||||
switch (gun[3]) {
|
||||
case "A":
|
||||
return 1;
|
||||
case "M":
|
||||
return 2;
|
||||
case "O":
|
||||
return 0;
|
||||
case "D":
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Detect
|
||||
{
|
||||
params: {
|
||||
name: "Detect",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{text}",
|
||||
},
|
||||
|
||||
text: [ "Zero", "Peak", "Level" ],
|
||||
|
||||
conversion: (gun, shot) => {
|
||||
switch (gun[4]) {
|
||||
case "P":
|
||||
return 1;
|
||||
case "Z":
|
||||
return 0;
|
||||
case "L":
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Autofire
|
||||
{
|
||||
params: {
|
||||
name: "Autofire",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{text}",
|
||||
},
|
||||
|
||||
text: [ "False", "True" ],
|
||||
|
||||
conversion: (gun, shot) => {
|
||||
return gun[5] ? 1 : 0;
|
||||
}
|
||||
},
|
||||
|
||||
// Aimpoint
|
||||
{
|
||||
params: {
|
||||
name: "Aimpoint",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} ms"
|
||||
},
|
||||
|
||||
conversion: (gun, shot) => gun[7]
|
||||
},
|
||||
|
||||
// Firetime
|
||||
{
|
||||
params: {
|
||||
name: "Firetime",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} ms"
|
||||
},
|
||||
|
||||
conversion: (gun, shot) => gun[2] == shot.meta.src_number ? gun[8] : null
|
||||
},
|
||||
|
||||
// Delta
|
||||
{
|
||||
params: {
|
||||
name: "Delta",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} ms",
|
||||
// NOTE: These values are based on
|
||||
// Grane + Snorre's ±1.5 ms spec. While a fairly
|
||||
// common range, I still consider these min / max
|
||||
// numbers to have been chosen semi-arbitrarily.
|
||||
zmin: -2,
|
||||
zmax: 2
|
||||
},
|
||||
|
||||
conversion: (gun, shot) => gun[2] == shot.meta.src_number ? gun[7]-gun[8] : null
|
||||
},
|
||||
|
||||
// Delay
|
||||
{
|
||||
params: {
|
||||
name: "Delay",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} ms"
|
||||
},
|
||||
|
||||
conversion: (gun, shot) => gun[9]
|
||||
},
|
||||
|
||||
// Depth
|
||||
{
|
||||
params: {
|
||||
name: "Depth",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} m"
|
||||
},
|
||||
|
||||
conversion: (gun, shot) => gun[10]
|
||||
},
|
||||
|
||||
// Pressure
|
||||
{
|
||||
params: {
|
||||
name: "Pressure",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} psi"
|
||||
},
|
||||
|
||||
conversion: (gun, shot) => gun[11]
|
||||
},
|
||||
|
||||
// Volume
|
||||
{
|
||||
params: {
|
||||
name: "Volume",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} in³"
|
||||
},
|
||||
|
||||
conversion: (gun, shot) => gun[12]
|
||||
},
|
||||
|
||||
// Filltime
|
||||
{
|
||||
params: {
|
||||
name: "Filltime",
|
||||
hovertemplate: "SP%{x}<br>%{y}<br>%{z} ms"
|
||||
},
|
||||
|
||||
// NOTE that filltime is applicable to the *non* firing guns
|
||||
conversion: (gun, shot) => gun[2] == shot.meta.src_number ? null : gun[13]
|
||||
}
|
||||
|
||||
|
||||
];
|
||||
|
||||
// Get gun numbers
|
||||
const guns = [...new Set(data.map( s => s.meta.guns.map( g => g[1] ) ).flat())];
|
||||
|
||||
// z eventually will have the structure:
|
||||
// z = {
|
||||
// [aspect]: [ // First shotpoint
|
||||
// [ // Value for gun 0, gun 1, … ],
|
||||
// …more shotpoints…
|
||||
// ]
|
||||
// }
|
||||
const z = {};
|
||||
|
||||
// x is an array of shotpoints
|
||||
const x = [];
|
||||
|
||||
// y is an array of gun numbers
|
||||
const y = guns.map( gun => `G${gun}` );
|
||||
|
||||
// Build array of guns (i.e., populate z)
|
||||
// We prefer to do this outside the shot-to-shot loop
|
||||
// for efficiency
|
||||
for (const facet of facets) {
|
||||
const label = facet.params.name;
|
||||
z[label] = Array(guns.length);
|
||||
for (let i=0; i<guns.length; i++) {
|
||||
z[label][i] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Populate array of guns with shotpoint data
|
||||
for (let shot of data) {
|
||||
x.push(shot.point);
|
||||
|
||||
for (const facet of facets) {
|
||||
const label = facet.params.name;
|
||||
const facetGunsArray = z[label];
|
||||
|
||||
for (const gun of shot.meta.guns) {
|
||||
const gunIndex = gun[1]-1;
|
||||
const facetGun = facetGunsArray[gunIndex];
|
||||
facetGun.push(facet.conversion(gun, shot));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aspects.map( (aspect, idx) => {
|
||||
const facet = facets.find(el => el.params.name == aspect) || {};
|
||||
|
||||
const defaultParams = {
|
||||
name: aspect,
|
||||
type: "heatmap",
|
||||
showscale: false,
|
||||
x,
|
||||
y,
|
||||
z: z[aspect],
|
||||
text: facet.text ? z[aspect].map(row => row.map(v => facet.text[v])) : undefined,
|
||||
xaxis: "x",
|
||||
yaxis: "y" + (idx > 0 ? idx+1 : "")
|
||||
}
|
||||
|
||||
|
||||
return Object.assign({}, defaultParams, facet.params);
|
||||
});
|
||||
}
|
||||
|
||||
const data = transform(this.data.items, this.aspects);
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
title: {text: "Gun details – sequence %{meta.sequence}"},
|
||||
height: 200*this.aspects.length,
|
||||
//autocolorscale: true,
|
||||
/*
|
||||
grid: {
|
||||
rows: this.aspects.length,
|
||||
columns: 1,
|
||||
pattern: "coupled",
|
||||
roworder: "bottom to top"
|
||||
},
|
||||
*/
|
||||
//autosize: true,
|
||||
// colorscale: "sequential",
|
||||
|
||||
xaxis: {
|
||||
title: "Shotpoint",
|
||||
showspikes: true
|
||||
},
|
||||
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
this.aspects.forEach ( (aspect, idx) => {
|
||||
const num = idx+1;
|
||||
const key = "yaxis" + num;
|
||||
const anchor = "y" + num;
|
||||
const segment = (1/this.aspects.length);
|
||||
const margin = segment/20;
|
||||
const domain = [
|
||||
segment*idx + margin,
|
||||
segment*num - margin
|
||||
];
|
||||
layout[key] = {
|
||||
title: aspect,
|
||||
anchor,
|
||||
domain
|
||||
}
|
||||
});
|
||||
|
||||
const config = {
|
||||
//editable: true,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph = Plotly.newPlot(this.$refs.graphHeat, data, layout, config);
|
||||
|
||||
},
|
||||
|
||||
replot () {
|
||||
if (!this.graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Replotting");
|
||||
Object.values(this.$refs).forEach( ref => {
|
||||
if (ref.data) {
|
||||
console.log("Replotting", ref, ref.clientWidth, ref.clientHeight);
|
||||
Plotly.relayout(ref, {
|
||||
width: ref.clientWidth,
|
||||
height: ref.clientHeight
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
|
||||
if (this.data) {
|
||||
this.plot();
|
||||
} else {
|
||||
this.busy = true;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.replot)
|
||||
this.resizeObserver.observe(this.$refs.graphHeat);
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.unobserve(this.$refs.graphHeat);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
</script>
|
||||
381
lib/www/client/source/src/components/graph-guns-pressure.vue
Normal file
381
lib/www/client/source/src/components/graph-guns-pressure.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<v-card style="min-height:400px;">
|
||||
<v-card-title class="headline">
|
||||
Gun pressures
|
||||
<v-spacer></v-spacer>
|
||||
<v-switch v-model="shotpoint" label="Shotpoint"></v-switch>
|
||||
<v-switch class="ml-4" v-model="violinplot" label="Violin plot"></v-switch>
|
||||
</v-card-title>
|
||||
|
||||
<v-container fluid fill-height>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphSeries"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="shotpoint">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphBar"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="violinplot">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphViolin"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-overlay :value="busy" absolute z-index="1">
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import * as d3a from 'd3-array';
|
||||
import Plotly from 'plotly.js-dist';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import unpack from '@/lib/unpack.js';
|
||||
import * as aes from '@/lib/graphs/aesthetics.js';
|
||||
|
||||
export default {
|
||||
name: 'DougalGraphGunsPressure',
|
||||
|
||||
props: [ "data", "settings" ],
|
||||
|
||||
data () {
|
||||
return {
|
||||
graph: null,
|
||||
graphHover: null,
|
||||
busy: false,
|
||||
resizeObserver: null,
|
||||
shotpoint: true,
|
||||
violinplot: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
//...mapGetters(['apiUrl'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
data (newVal, oldVal) {
|
||||
console.log("data changed");
|
||||
|
||||
if (newVal === null) {
|
||||
this.busy = true;
|
||||
} else {
|
||||
this.busy = false;
|
||||
this.plot();
|
||||
}
|
||||
},
|
||||
|
||||
settings () {
|
||||
for (const key in this.settings) {
|
||||
this[key] = this.settings[key];
|
||||
}
|
||||
},
|
||||
|
||||
shotpoint () {
|
||||
if (this.shotpoint) {
|
||||
this.replot();
|
||||
}
|
||||
this.$emit("update:settings", {[`${this.$options.name}.shotpoint`]: this.shotpoint});
|
||||
},
|
||||
|
||||
violinplot () {
|
||||
if (this.violinplot) {
|
||||
this.plotViolin();
|
||||
}
|
||||
this.$emit("update:settings", {[`${this.$options.name}.violinplot`]: this.violinplot});
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
plot () {
|
||||
this.plotSeries();
|
||||
if (this.violinplot) {
|
||||
this.plotViolin();
|
||||
}
|
||||
},
|
||||
|
||||
async plotSeries () {
|
||||
|
||||
function transformSeries (d, src_number, otherParams={}) {
|
||||
|
||||
const meta = src_number
|
||||
? unpack(d, "meta").filter( s => s.src_number == src_number )
|
||||
: unpack(d, "meta");
|
||||
const guns = unpack(meta, "guns").map(s => s.filter(g => g[2] == src_number));;
|
||||
const gunPressures = guns.map(s => s.map(g => g[11]));
|
||||
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) =>
|
||||
d3a.sum(s.map( (pressure, gidx) => pressure * gunPressureWeights[sidx][gidx] )) / d3a.sum(gunPressureWeights[sidx])
|
||||
);
|
||||
|
||||
const manifold = unpack(meta, "manifold");
|
||||
const x = src_number
|
||||
? unpack(d.filter(s => s.meta.src_number == src_number), "point")
|
||||
: unpack(d, "point");
|
||||
|
||||
const traceManifold = {
|
||||
name: "Manifold",
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
line: { ...aes.gunArrays[src_number || 1].avg.line, dash: "dot", width: 1 },
|
||||
x,
|
||||
y: manifold,
|
||||
};
|
||||
|
||||
const tracesGunPressures = [{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
x,
|
||||
y: gunPressuresSorted.map(s => d3a.quantileSorted(s, 0.25)),
|
||||
...aes.gunArrays[src_number || 1].min
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
fill: "tonexty",
|
||||
x,
|
||||
y: gunsWeightedAvgPressure,
|
||||
...aes.gunArrays[src_number || 1].avg
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
fill: "tonexty",
|
||||
x,
|
||||
y: gunPressuresSorted.map(s => d3a.quantileSorted(s, 0.75)),
|
||||
...aes.gunArrays[src_number || 1].max
|
||||
}];
|
||||
|
||||
const tracesGunsPressuresIndividual = {
|
||||
//name: `Array ${src_number} outliers`,
|
||||
type: "scatter",
|
||||
mode: "markers",
|
||||
marker: {size: 2 },
|
||||
hoverinfo: "skip",
|
||||
x: gunPressuresSorted.map( (s, idx) =>
|
||||
s.filter( g => g < d3a.quantileSorted(s, 0.05) || g > d3a.quantileSorted(s, 0.95))
|
||||
.map( f => Array(f.length).fill(x[idx]) ).flat()
|
||||
).flat(),
|
||||
y: gunPressuresSorted.map( (s, idx) =>
|
||||
s.filter( g => g < d3a.quantileSorted(s, 0.05) || g > d3a.quantileSorted(s, 0.95))
|
||||
).flat(),
|
||||
...aes.gunArrays[src_number || 1].out
|
||||
};
|
||||
|
||||
const data = [ traceManifold, ...tracesGunPressures, tracesGunsPressuresIndividual ]
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = [ ...new Set(unpack(this.data.items, "meta").map( s => s.src_number ))];
|
||||
const data = sources.map( src_number => transformSeries(this.data.items, src_number) ).flat();
|
||||
console.log("Sources", sources);
|
||||
console.log(data);
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
title: {text: "Gun pressures – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
hovermode: "x",
|
||||
yaxis: {
|
||||
title: "Pressure (psi)",
|
||||
//zeroline: false
|
||||
},
|
||||
xaxis: {
|
||||
title: "Shotpoint",
|
||||
showspikes: true
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph = Plotly.newPlot(this.$refs.graphSeries, data, layout, config);
|
||||
this.$refs.graphSeries.on('plotly_hover', (d) => {
|
||||
const point = d.points[0].x;
|
||||
const item = this.data.items.find(s => s.point == point);
|
||||
const guns = item.meta.guns.filter( g => g[2] == item.meta.src_number );
|
||||
const gunIds = guns.map( g => "G"+g[1] );
|
||||
const pressures = unpack(guns, 11);
|
||||
const volumes = unpack(guns, 12);
|
||||
const maxVolume = d3a.max(volumes);
|
||||
const data = [{
|
||||
type: "bar",
|
||||
x: gunIds,
|
||||
y: pressures,
|
||||
width: volumes.map( v => v/maxVolume ),
|
||||
transforms: [{
|
||||
type: "groupby",
|
||||
groups: unpack(guns, 0)
|
||||
}],
|
||||
}];
|
||||
|
||||
const layout = {
|
||||
title: {text: "Gun pressures – shot %{meta.point}"},
|
||||
height: 300,
|
||||
yaxis: {
|
||||
title: "Pressure (psi)",
|
||||
range: [ Math.min(d3a.min(pressures), 1950), Math.max(d3a.max(pressures), 2050) ]
|
||||
},
|
||||
xaxis: {
|
||||
title: "Gun number",
|
||||
type: 'category'
|
||||
},
|
||||
meta: {
|
||||
point
|
||||
}
|
||||
};
|
||||
|
||||
const config = { displaylogo: false };
|
||||
|
||||
Plotly.react(this.$refs.graphBar, data, layout, config);
|
||||
});
|
||||
},
|
||||
|
||||
async plotViolin () {
|
||||
|
||||
function transformViolin (d, opts = {}) {
|
||||
|
||||
const styles = [];
|
||||
|
||||
unpack(unpack(d, "meta"), "guns").flat().forEach(i => {
|
||||
const gunId = i[1];
|
||||
const arrayId = i[2];
|
||||
if (!styles[gunId]) {
|
||||
styles[gunId] = Object.assign({target: gunId}, aes.gunArrayViolins[arrayId]);
|
||||
}
|
||||
});
|
||||
|
||||
const data = {
|
||||
type: 'violin',
|
||||
x: unpack(unpack(unpack(d, "meta"), "guns").flat(), 1), // Gun number
|
||||
y: unpack(unpack(unpack(d, "meta"), "guns").flat(), 11), // Gun pressure
|
||||
points: 'none',
|
||||
box: {
|
||||
visible: true
|
||||
},
|
||||
line: {
|
||||
color: 'green',
|
||||
},
|
||||
meanline: {
|
||||
visible: true
|
||||
},
|
||||
transforms: [{
|
||||
type: 'groupby',
|
||||
groups: unpack(unpack(unpack(d, "meta"), "guns").flat(), 1),
|
||||
styles: styles.filter(i => !!i)
|
||||
}]
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
console.log("plot violin");
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
console.log("Will plot sequence", this.data.meta.project, this.data.meta.sequence);
|
||||
|
||||
const data = [ transformViolin(this.data.items) ];
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
showlegend: false,
|
||||
title: {text: "Individual gun pressures – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
yaxis: {
|
||||
title: "Pressure (psi)",
|
||||
zeroline: false
|
||||
},
|
||||
xaxis: {
|
||||
title: "Gun number"
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph = Plotly.newPlot(this.$refs.graphViolin, data, layout, config);
|
||||
},
|
||||
|
||||
|
||||
replot () {
|
||||
if (!this.graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Replotting");
|
||||
Object.values(this.$refs).forEach( ref => {
|
||||
if (ref.data) {
|
||||
console.log("Replotting", ref, ref.clientWidth, ref.clientHeight);
|
||||
Plotly.relayout(ref, {
|
||||
width: ref.clientWidth,
|
||||
height: ref.clientHeight
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
|
||||
if (this.data) {
|
||||
this.plot();
|
||||
} else {
|
||||
this.busy = true;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.replot)
|
||||
this.resizeObserver.observe(this.$refs.graphSeries);
|
||||
this.resizeObserver.observe(this.$refs.graphViolin);
|
||||
this.resizeObserver.observe(this.$refs.graphBar);
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.unobserve(this.$refs.graphBar);
|
||||
this.resizeObserver.unobserve(this.$refs.graphViolin);
|
||||
this.resizeObserver.unobserve(this.$refs.graphSeries);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
</script>
|
||||
364
lib/www/client/source/src/components/graph-guns-timing.vue
Normal file
364
lib/www/client/source/src/components/graph-guns-timing.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<v-card style="min-height:400px;">
|
||||
<v-card-title class="headline">
|
||||
Gun timing
|
||||
<v-spacer></v-spacer>
|
||||
<v-switch v-model="shotpoint" label="Shotpoint"></v-switch>
|
||||
<v-switch class="ml-4" v-model="violinplot" label="Violin plot"></v-switch>
|
||||
</v-card-title>
|
||||
|
||||
<v-container fluid fill-height>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphSeries"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="shotpoint">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphBar"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-show="violinplot">
|
||||
<v-col>
|
||||
<div class="graph-container" ref="graphViolin"></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-overlay :value="busy" absolute z-index="1">
|
||||
<v-progress-circular indeterminate></v-progress-circular>
|
||||
</v-overlay>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import * as d3a from 'd3-array';
|
||||
import Plotly from 'plotly.js-dist';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import unpack from '@/lib/unpack.js';
|
||||
import * as aes from '@/lib/graphs/aesthetics.js';
|
||||
|
||||
export default {
|
||||
name: 'DougalGraphGunsTiming',
|
||||
|
||||
props: [ "data", "settings" ],
|
||||
|
||||
data () {
|
||||
return {
|
||||
graph: null,
|
||||
graphHover: null,
|
||||
busy: false,
|
||||
resizeObserver: null,
|
||||
shotpoint: true,
|
||||
violinplot: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
//...mapGetters(['apiUrl'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
data (newVal, oldVal) {
|
||||
console.log("data changed");
|
||||
|
||||
if (newVal === null) {
|
||||
this.busy = true;
|
||||
} else {
|
||||
this.busy = false;
|
||||
this.plot();
|
||||
}
|
||||
},
|
||||
|
||||
settings () {
|
||||
for (const key in this.settings) {
|
||||
this[key] = this.settings[key];
|
||||
}
|
||||
},
|
||||
|
||||
shotpoint () {
|
||||
if (this.shotpoint) {
|
||||
this.replot();
|
||||
}
|
||||
this.$emit("update:settings", {[`${this.$options.name}.shotpoint`]: this.shotpoint});
|
||||
},
|
||||
|
||||
violinplot () {
|
||||
if (this.violinplot) {
|
||||
this.plotViolin();
|
||||
}
|
||||
this.$emit("update:settings", {[`${this.$options.name}.violinplot`]: this.violinplot});
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
plot () {
|
||||
this.plotSeries();
|
||||
if (this.violinplot) {
|
||||
this.plotViolin();
|
||||
}
|
||||
},
|
||||
|
||||
async plotSeries () {
|
||||
|
||||
function transformSeries (d, src_number, otherParams={}) {
|
||||
|
||||
const meta = src_number
|
||||
? unpack(d, "meta").filter( s => s.src_number == src_number )
|
||||
: unpack(d, "meta");
|
||||
const guns = unpack(meta, "guns").map(s => s.filter(g => g[2] == src_number));;
|
||||
const gunTimings = guns.map(s => s.map(g => g[9]));
|
||||
const gunTimingsSorted = gunTimings.map(s => d3a.sort(s));
|
||||
const gunsAvgTiming = gunTimings.map( (s, sidx) => d3a.mean(s) );
|
||||
|
||||
const x = src_number
|
||||
? unpack(d.filter(s => s.meta.src_number == src_number), "point")
|
||||
: unpack(d, "point");
|
||||
|
||||
const tracesGunTimings = [{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
x,
|
||||
y: gunTimingsSorted.map(s => d3a.quantileSorted(s, 0.25)),
|
||||
...aes.gunArrays[src_number || 1].min
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
fill: "tonexty",
|
||||
x,
|
||||
y: gunsAvgTiming,
|
||||
...aes.gunArrays[src_number || 1].avg
|
||||
},
|
||||
{
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
fill: "tonexty",
|
||||
x,
|
||||
y: gunTimingsSorted.map(s => d3a.quantileSorted(s, 0.75)),
|
||||
...aes.gunArrays[src_number || 1].max
|
||||
}];
|
||||
|
||||
const tracesGunsTimingsIndividual = {
|
||||
//name: `Array ${src_number} outliers`,
|
||||
type: "scatter",
|
||||
mode: "markers",
|
||||
marker: {size: 2 },
|
||||
hoverinfo: "skip",
|
||||
x: gunTimingsSorted.map( (s, idx) =>
|
||||
s.filter( g => g < d3a.quantileSorted(s, 0.05) || g > d3a.quantileSorted(s, 0.95))
|
||||
.map( f => Array(f.length).fill(x[idx]) ).flat()
|
||||
).flat(),
|
||||
y: gunTimingsSorted.map( (s, idx) =>
|
||||
s.filter( g => g < d3a.quantileSorted(s, 0.05) || g > d3a.quantileSorted(s, 0.95))
|
||||
).flat(),
|
||||
...aes.gunArrays[src_number || 1].out
|
||||
};
|
||||
|
||||
const data = [ ...tracesGunTimings, tracesGunsTimingsIndividual ]
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = [ ...new Set(unpack(this.data.items, "meta").map( s => s.src_number ))];
|
||||
const data = sources.map( src_number => transformSeries(this.data.items, src_number) ).flat();
|
||||
console.log("Sources", sources);
|
||||
console.log(data);
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
title: {text: "Gun timings – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
hovermode: "x",
|
||||
yaxis: {
|
||||
title: "Timing (ms)",
|
||||
//zeroline: false
|
||||
},
|
||||
xaxis: {
|
||||
title: "Shotpoint",
|
||||
showspikes: true
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph = Plotly.newPlot(this.$refs.graphSeries, data, layout, config);
|
||||
this.$refs.graphSeries.on('plotly_hover', (d) => {
|
||||
const point = d.points[0].x;
|
||||
const item = this.data.items.find(s => s.point == point);
|
||||
const guns = item.meta.guns.filter( g => g[2] == item.meta.src_number );
|
||||
const gunIds = guns.map( g => "G"+g[1] );
|
||||
const timings = unpack(guns, 9);
|
||||
const data = [{
|
||||
type: "bar",
|
||||
x: gunIds,
|
||||
y: timings,
|
||||
transforms: [{
|
||||
type: "groupby",
|
||||
groups: unpack(guns, 0)
|
||||
}],
|
||||
}];
|
||||
|
||||
const layout = {
|
||||
title: {text: "Gun timings – shot %{meta.point}"},
|
||||
height: 300,
|
||||
yaxis: {
|
||||
title: "Timing (ms)",
|
||||
range: [ Math.min(d3a.min(timings), 10), Math.max(d3a.max(timings), 20) ]
|
||||
},
|
||||
xaxis: {
|
||||
title: "Gun number",
|
||||
type: 'category'
|
||||
},
|
||||
meta: {
|
||||
point
|
||||
}
|
||||
};
|
||||
|
||||
const config = { displaylogo: false };
|
||||
|
||||
Plotly.react(this.$refs.graphBar, data, layout, config);
|
||||
});
|
||||
},
|
||||
|
||||
async plotViolin () {
|
||||
|
||||
function transformViolin (d, opts = {}) {
|
||||
|
||||
const styles = [];
|
||||
|
||||
unpack(unpack(d, "meta"), "guns").flat().forEach(i => {
|
||||
const gunId = i[1];
|
||||
const arrayId = i[2];
|
||||
if (!styles[gunId]) {
|
||||
styles[gunId] = Object.assign({target: gunId}, aes.gunArrayViolins[arrayId]);
|
||||
}
|
||||
});
|
||||
|
||||
const data = {
|
||||
type: 'violin',
|
||||
x: unpack(unpack(unpack(d, "meta"), "guns").flat(), 1), // Gun number
|
||||
y: unpack(unpack(unpack(d, "meta"), "guns").flat(), 9), // Gun timing
|
||||
points: 'none',
|
||||
box: {
|
||||
visible: true
|
||||
},
|
||||
line: {
|
||||
color: 'green',
|
||||
},
|
||||
meanline: {
|
||||
visible: true
|
||||
},
|
||||
transforms: [{
|
||||
type: 'groupby',
|
||||
groups: unpack(unpack(unpack(d, "meta"), "guns").flat(), 1),
|
||||
styles: styles.filter(i => !!i)
|
||||
}]
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
console.log("plot violin");
|
||||
if (!this.data) {
|
||||
console.log("missing data");
|
||||
return;
|
||||
}
|
||||
console.log("Will plot sequence", this.data.meta.project, this.data.meta.sequence);
|
||||
|
||||
const data = [ transformViolin(this.data.items) ];
|
||||
this.busy = false;
|
||||
|
||||
const layout = {
|
||||
//autosize: true,
|
||||
showlegend: false,
|
||||
title: {text: "Individual gun timings – sequence %{meta.sequence}"},
|
||||
autocolorscale: true,
|
||||
// colorscale: "sequential",
|
||||
yaxis: {
|
||||
title: "Timing (ms)",
|
||||
zeroline: false
|
||||
},
|
||||
xaxis: {
|
||||
title: "Gun number"
|
||||
},
|
||||
meta: this.data.meta
|
||||
};
|
||||
|
||||
const config = {
|
||||
editable: false,
|
||||
displaylogo: false
|
||||
};
|
||||
|
||||
this.graph = Plotly.newPlot(this.$refs.graphViolin, data, layout, config);
|
||||
},
|
||||
|
||||
|
||||
replot () {
|
||||
if (!this.graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Replotting");
|
||||
Object.values(this.$refs).forEach( ref => {
|
||||
if (ref.data) {
|
||||
console.log("Replotting", ref, ref.clientWidth, ref.clientHeight);
|
||||
Plotly.relayout(ref, {
|
||||
width: ref.clientWidth,
|
||||
height: ref.clientHeight
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
|
||||
},
|
||||
|
||||
mounted () {
|
||||
|
||||
if (this.data) {
|
||||
this.plot();
|
||||
} else {
|
||||
this.busy = true;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.replot)
|
||||
this.resizeObserver.observe(this.$refs.graphSeries);
|
||||
this.resizeObserver.observe(this.$refs.graphViolin);
|
||||
this.resizeObserver.observe(this.$refs.graphBar);
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.unobserve(this.$refs.graphBar);
|
||||
this.resizeObserver.unobserve(this.$refs.graphViolin);
|
||||
this.resizeObserver.unobserve(this.$refs.graphSeries);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
</script>
|
||||
145
lib/www/client/source/src/components/graph-settings-sequence.vue
Normal file
145
lib/www/client/source/src/components/graph-settings-sequence.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
|
||||
<v-dialog v-model="open">
|
||||
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn icon v-bind="attrs" v-on="on" title="Configure visible aspects">
|
||||
<v-icon small>mdi-wrench-outline</v-icon>
|
||||
</v-btn>
|
||||
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-list nav subheader>
|
||||
|
||||
<v-subheader>Visualisations</v-subheader>
|
||||
|
||||
<v-list-item-group v-model="aspectsVisible" multiple>
|
||||
|
||||
<v-list-item value="DougalGraphGunsPressure">
|
||||
<template v-slot:default="{ active }">
|
||||
<v-list-item-action>
|
||||
<v-checkbox :input-value="active"></v-checkbox>
|
||||
</v-list-item-action>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Series: Gun pressure</v-list-item-title>
|
||||
<v-list-item-subtitle>Array pressures weighted averages</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item value="DougalGraphGunsTiming">
|
||||
<template v-slot:default="{ active }">
|
||||
<v-list-item-action>
|
||||
<v-checkbox :input-value="active"></v-checkbox>
|
||||
</v-list-item-action>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Series: Gun timing</v-list-item-title>
|
||||
<v-list-item-subtitle>Array timing averages</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item value="DougalGraphGunsDepth">
|
||||
<template v-slot:default="{ active }">
|
||||
<v-list-item-action>
|
||||
<v-checkbox :input-value="active"></v-checkbox>
|
||||
</v-list-item-action>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Series: Gun depth</v-list-item-title>
|
||||
<v-list-item-subtitle>Array depths averages</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item value="DougalGraphGunsHeatmap">
|
||||
<template v-slot:default="{ active }">
|
||||
<v-list-item-action>
|
||||
<v-checkbox :input-value="active"></v-checkbox>
|
||||
</v-list-item-action>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Heatmap: Gun parameters</v-list-item-title>
|
||||
<v-list-item-subtitle>Detail of every gun × every shotpoint</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item value="DougalGraphArraysIJScatter">
|
||||
<template v-slot:default="{ active }">
|
||||
<v-list-item-action>
|
||||
<v-checkbox :input-value="active"></v-checkbox>
|
||||
</v-list-item-action>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Series: I/J error</v-list-item-title>
|
||||
<v-list-item-subtitle>Inline / crossline error</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
</v-list-item-group>
|
||||
</v-list>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions>
|
||||
<v-btn v-if="user" color="warning" text @click="save" :title="'Save as preference for user '+user.name+' on this computer (other users may have other defaults).'">Save as default</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" text @click="open=false">Close</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: "DougalGraphSettingsSequence",
|
||||
|
||||
props: [
|
||||
"aspects"
|
||||
],
|
||||
|
||||
data () {
|
||||
return {
|
||||
open: false,
|
||||
aspectsVisible: this.aspects || []
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
aspects () {
|
||||
// Update the aspects selection list iff the list
|
||||
// is not currently open.
|
||||
if (!this.open) {
|
||||
this.aspectsVisible = this.aspects;
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
save () {
|
||||
this.open = false;
|
||||
this.$nextTick( () => this.$emit("update:aspects", {aspects: [...this.aspectsVisible]}) );
|
||||
},
|
||||
|
||||
reset () {
|
||||
this.aspectsVisible = this.aspects || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -33,7 +33,8 @@
|
||||
text
|
||||
:href="`mailto:${email}?Subject=Question`"
|
||||
>
|
||||
Ask a question
|
||||
<v-icon class="d-lg-none">mdi-help-circle</v-icon>
|
||||
<span class="d-none d-lg-inline">Ask a question</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@@ -41,7 +42,17 @@
|
||||
text
|
||||
href="mailto:dougal-support@aaltronav.eu?Subject=Bug report"
|
||||
>
|
||||
Report a bug
|
||||
<v-icon class="d-lg-none">mdi-bug</v-icon>
|
||||
<span class="d-none d-lg-inline">Report a bug</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="info"
|
||||
text
|
||||
:href='"/feed/"+feed'
|
||||
title="View development log"
|
||||
>
|
||||
<v-icon>mdi-rss</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
@@ -52,7 +63,8 @@
|
||||
text
|
||||
@click="dialog=false"
|
||||
>
|
||||
Close
|
||||
<v-icon class="d-lg-none">mdi-close-circle</v-icon>
|
||||
<span class="d-none d-lg-inline">Close</span>
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
@@ -69,7 +81,8 @@ export default {
|
||||
data () {
|
||||
return {
|
||||
dialog: false,
|
||||
email: "dougal-support@aaltronav.eu"
|
||||
email: "dougal-support@aaltronav.eu",
|
||||
feed: btoa(encodeURIComponent("https://gitlab.com/wgp/dougal/software.atom?feed_token=XSPpvsYEny8YmH75Nz5W"))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
background-color green
|
||||
&.online
|
||||
background-color blue
|
||||
&.planned
|
||||
background-color magenta
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -96,7 +98,9 @@ export default {
|
||||
? "Acquired"
|
||||
: s.status == "ntbp"
|
||||
? "NTBP"
|
||||
: s.status;
|
||||
: s.status == "planned"
|
||||
? "Planned"
|
||||
: s.status;
|
||||
|
||||
const remarks = "\n"+[s.remarks, s.remarks_final].join("\n").trim()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
app
|
||||
clipped-left
|
||||
>
|
||||
<v-img src="https://aaltronav.eu/media/aaltronav-logo.svg"
|
||||
<v-img src="/wgp-logo.png"
|
||||
contain
|
||||
max-height="32px" max-width="32px"
|
||||
></v-img>
|
||||
@@ -12,17 +12,70 @@
|
||||
<v-toolbar-title class="mx-2" @click="$router.push('/')" style="cursor: pointer;">Dougal</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-menu bottom offset-y>
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-hover v-slot="{hover}">
|
||||
<v-btn
|
||||
class="align-self-center"
|
||||
:xcolor="hover ? 'secondary' : 'secondary lighten-3'"
|
||||
small
|
||||
text
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
title="Settings"
|
||||
>
|
||||
<v-icon small>mdi-cog-outline</v-icon>
|
||||
</v-btn>
|
||||
</v-hover>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item :href="`/settings/equipment`">
|
||||
<v-list-item-title>Equipment list</v-list-item-title>
|
||||
<v-list-item-action><v-icon small>mdi-view-list</v-icon></v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
</v-menu>
|
||||
|
||||
|
||||
<v-breadcrumbs :items="path"></v-breadcrumbs>
|
||||
|
||||
<template v-if="$route.name != 'Login'">
|
||||
<v-btn text link to="/login" v-if="!$root.user">Log in</v-btn>
|
||||
<template v-else>
|
||||
<v-btn title="Edit profile" disabled>
|
||||
{{$root.user.name}}
|
||||
</v-btn>
|
||||
<v-btn class="ml-2" title="Log out" link to="/?logout=1">
|
||||
<v-icon>mdi-logout</v-icon>
|
||||
<v-btn text link to="/login" v-if="!user && !loading">Log in</v-btn>
|
||||
<template v-else-if="user">
|
||||
|
||||
<v-menu
|
||||
offset-y
|
||||
>
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-avatar :color="user.colour || 'primary'" :title="`${user.name} (${user.role})`" v-bind="attrs" v-on="on">
|
||||
<span class="white--text">{{user.name.slice(0, 5)}}</span>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item link to="/login" v-if="user.autologin">
|
||||
<v-list-item-icon><v-icon small>mdi-login</v-icon></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>Log in as a different user</v-list-item-title>
|
||||
<v-list-item-subtitle>Autologin from {{user.ip}}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item link to="/logout" v-else>
|
||||
<v-list-item-icon><v-icon small>mdi-logout</v-icon></v-list-item-icon>
|
||||
<v-list-item-title>Log out</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
</v-menu>
|
||||
|
||||
<!--
|
||||
<v-btn small text class="ml-2" title="Log out" link to="/?logout=1">
|
||||
<v-icon small>mdi-logout</v-icon>
|
||||
</v-btn>
|
||||
-->
|
||||
</template>
|
||||
</template>
|
||||
<template v-slot:extension v-if="$route.matched.find(i => i.name == 'Project')">
|
||||
@@ -35,6 +88,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'DougalNavigation',
|
||||
@@ -49,6 +103,7 @@ export default {
|
||||
{ href: "calendar", text: "Calendar" },
|
||||
{ href: "log", text: "Log" },
|
||||
{ href: "qc", text: "QC" },
|
||||
{ href: "graphs", text: "Graphs" },
|
||||
{ href: "map", text: "Map" }
|
||||
],
|
||||
path: []
|
||||
@@ -58,7 +113,9 @@ export default {
|
||||
computed: {
|
||||
tab () {
|
||||
return this.tabs.findIndex(t => t.href == this.$route.path.split(/\/+/)[3]);
|
||||
}
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'loading'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
88
lib/www/client/source/src/lib/graphs/aesthetics.js
Normal file
88
lib/www/client/source/src/lib/graphs/aesthetics.js
Normal file
@@ -0,0 +1,88 @@
|
||||
export const gunArrays = {
|
||||
1: {
|
||||
min: {
|
||||
fillcolor: "rgba(200, 230, 201, 0.2)",
|
||||
line: {color: "rgba(129, 199, 132, 0.3)", shape: "spline"},
|
||||
showlegend: false,
|
||||
name: "Array 1 (min.)",
|
||||
hoverinfo: "skip"
|
||||
},
|
||||
avg: {
|
||||
fillcolor: "rgba(200, 230, 201, 0.2)",
|
||||
line: {color: "rgba(129, 199, 132, 0.9)", shape: "spline"},
|
||||
name: "Array 1 (avg.)"
|
||||
},
|
||||
max: {
|
||||
fillcolor: "rgba(200, 230, 201, 0.2)",
|
||||
line: {color: "rgba(129, 199, 132, 0.4)", shape: "spline"},
|
||||
showlegend: false,
|
||||
name: "Array 1 (max.)",
|
||||
hoverinfo: "skip"
|
||||
},
|
||||
out: {
|
||||
name: "Array 1 outliers",
|
||||
line: {color: "rgba(129, 199, 166, 0.7)"},
|
||||
fillcolor: "rgba(129, 199, 166, 0.5)"
|
||||
}
|
||||
},
|
||||
2: {
|
||||
min: {
|
||||
fillcolor: "rgba(255, 205, 210, 0.2)",
|
||||
line: {color: "rgba(229, 115, 115, 0.3)", shape: "spline"},
|
||||
showlegend: false,
|
||||
name: "Array 2 (min.)",
|
||||
hoverinfo: "skip"
|
||||
},
|
||||
avg: {
|
||||
fillcolor: "rgba(255, 205, 210, 0.2)",
|
||||
line: {color: "rgba(229, 115, 115, 0.9)", shape: "spline"},
|
||||
name: "Array 2 (avg.)"
|
||||
},
|
||||
max: {
|
||||
fillcolor: "rgba(255, 205, 210, 0.2)",
|
||||
line: {color: "rgba(229, 115, 115, 0.4)", shape: "spline"},
|
||||
showlegend: false,
|
||||
name: "Array 2 (max.)",
|
||||
hoverinfo: "skip"
|
||||
},
|
||||
out: {
|
||||
name: "Array 2 outliers",
|
||||
line: {color: "rgba(229, 153, 115, 0.7)"},
|
||||
fillcolor: "rgba(229, 153, 115, 0.5)"
|
||||
}
|
||||
},
|
||||
3: {
|
||||
min: {
|
||||
fillcolor: "",
|
||||
line: {color: "", shape: "spline"},
|
||||
showlegend: false,
|
||||
name: "Array 3 (min.)",
|
||||
hoverinfo: "skip"
|
||||
},
|
||||
avg: {
|
||||
fillcolor: "",
|
||||
line: {color: "", shape: "spline"},
|
||||
name: "Array 3 (avg.)"
|
||||
},
|
||||
max: {
|
||||
fillcolor: "",
|
||||
line: {color: "", shape: "spline"},
|
||||
showlegend: false,
|
||||
name: "Array 3 (max.)",
|
||||
hoverinfo: "skip"
|
||||
},
|
||||
out: {
|
||||
name: "Array 3 outliers",
|
||||
//fillcolor: ""
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const gunArrayViolins = {
|
||||
1: {
|
||||
value: {line: {color: "rgba(129, 199, 132, 0.9)"}}
|
||||
},
|
||||
2: {
|
||||
value: {line: {color: "rgba(229, 115, 115, 0.9)"}}
|
||||
}
|
||||
};
|
||||
11
lib/www/client/source/src/lib/markdown.js
Normal file
11
lib/www/client/source/src/lib/markdown.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const marked = require('marked');
|
||||
|
||||
function markdown (str) {
|
||||
return marked(String(str));
|
||||
}
|
||||
|
||||
function markdownInline (str) {
|
||||
return marked.parseInline(String(str));
|
||||
}
|
||||
|
||||
module.exports = { markdown, markdownInline };
|
||||
@@ -1,194 +0,0 @@
|
||||
|
||||
let ws = null;
|
||||
let peerId = null;
|
||||
let peers = {};
|
||||
let stream = null;
|
||||
|
||||
function init (socket) {
|
||||
ws = socket;
|
||||
ws.addEventListener("message", (ev) => {
|
||||
try {
|
||||
const payload = JSON.parse(ev.data);
|
||||
if (payload.rtc === true) {
|
||||
// Handle this message
|
||||
handle (payload);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Invalid message", ev, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function talk () {
|
||||
try {
|
||||
if (!stream) {
|
||||
const constraints = { audio: true, video: false };
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
console.log("Grabbed stream", stream);
|
||||
}
|
||||
|
||||
if (peerId && Object.keys(peers).length) {
|
||||
for (const track of stream.getTracks()) {
|
||||
for (const peer in peers) {
|
||||
peers[peer].addTrack(track, stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("talk() error", err);
|
||||
}
|
||||
}
|
||||
|
||||
class PeerConnection {
|
||||
|
||||
constructor (otherPeerId) {
|
||||
this.otherPeerId = otherPeerId;
|
||||
this.makingOffer = false;
|
||||
this.polite = this.otherPeerId > peerId;
|
||||
this.start();
|
||||
}
|
||||
|
||||
send (message) {
|
||||
message.from = peerId;
|
||||
message.to = this.otherPeerId;
|
||||
send(message);
|
||||
}
|
||||
|
||||
async handle (message) {
|
||||
try {
|
||||
let ignoreOffer = false;
|
||||
|
||||
if ("description" in message) {
|
||||
const offerCollision = (message.description.type == "offer") &&
|
||||
(this.makingOffer || this.conn.signalingState != "stable");
|
||||
|
||||
ignoreOffer = !this.polite && offerCollision;
|
||||
|
||||
if (ignoreOffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.conn.setRemoteDescription(message.description);
|
||||
if (message.description.type == "offer") {
|
||||
await this.conn.setLocalDescription();
|
||||
this.send({ description: this.conn.localDescription });
|
||||
}
|
||||
}
|
||||
|
||||
if ("candidate" in message) {
|
||||
try {
|
||||
await this.conn.addIceCandidate(message.candidate);
|
||||
} catch (err) {
|
||||
if (!ignoreOffer) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
start () {
|
||||
const config = { iceServers: [] };
|
||||
this.conn = new RTCPeerConnection(config);
|
||||
console.log("Have peer connection", this.conn);
|
||||
|
||||
this.conn.ontrack = ({track, streams}) => {
|
||||
// FIXME Need to remove these elements when done
|
||||
if (!this.remoteAudio) {
|
||||
this.remoteAudio = document.createElement("audio");
|
||||
this.remoteAudio.setAttribute("autoplay", "true");
|
||||
this.remoteAudio.controls = true;
|
||||
document.getElementsByTagName("footer")[0].appendChild(this.remoteAudio);
|
||||
console.log("Added <audio> element", this.remoteAudio);
|
||||
}
|
||||
|
||||
track.onunmute = () => {
|
||||
if (this.remoteAudio.srcObject) {
|
||||
return;
|
||||
}
|
||||
this.remoteAudio.srcObject = streams[0];
|
||||
console.log("unmuted");
|
||||
};
|
||||
};
|
||||
|
||||
this.conn.onnegotiationneeded = async () => {
|
||||
console.log("negotiation needed");
|
||||
try {
|
||||
this.makingOffer = true;
|
||||
await this.conn.setLocalDescription();
|
||||
this.send({ description: this.conn.localDescription });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
this.makingOffer = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.conn.oniceconnectionstatechange = () => {
|
||||
console.log("state change");
|
||||
if (this.conn.iceConnectionState == "failed") {
|
||||
this.conn.restartIce();
|
||||
}
|
||||
}
|
||||
|
||||
this.conn.onicecandidate = async ({candidate}) => {
|
||||
console.log("send candidate", candidate);
|
||||
this.send({candidate});
|
||||
};
|
||||
|
||||
if (stream) {
|
||||
for (const track of stream.getAudioTracks()) {
|
||||
this.addTrack(track, stream);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
addTrack (track, stream) {
|
||||
console.log("add track to connection", this.conn, track, stream);
|
||||
if (this.conn) {
|
||||
this.conn.addTrack(track, stream);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
function send (message) {
|
||||
console.log("Send message", message, "via", ws);
|
||||
if (ws) {
|
||||
message.rtc = true;
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function handle (message) {
|
||||
console.log("Handle message", message);
|
||||
|
||||
if ("peerId" in message) {
|
||||
peerId = message.peerId;
|
||||
}
|
||||
|
||||
if ("otherPeers" in message) {
|
||||
for (const peer of message.otherPeers) {
|
||||
if (peer in peers) continue;
|
||||
|
||||
peers[peer] = new PeerConnection(peer);
|
||||
}
|
||||
}
|
||||
|
||||
if ("newPeer" in message) {
|
||||
peers[message.newPeer] = new PeerConnection(message.newPeer);
|
||||
}
|
||||
|
||||
if ("to" in message && "from" in message && message.to == peerId) {
|
||||
peers[message.from].handle(message);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
init, talk
|
||||
};
|
||||
4
lib/www/client/source/src/lib/unpack.js
Normal file
4
lib/www/client/source/src/lib/unpack.js
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
export default function unpack(rows, key) {
|
||||
return rows && rows.map( row => row[key] );
|
||||
};
|
||||
@@ -26,4 +26,116 @@ function withParentProps(item, parent, childrenKey, prop, currentValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
export { withParentProps }
|
||||
function dms (lat, lon) {
|
||||
const λh = lat < 0 ? "S" : "N";
|
||||
const φh = lon < 0 ? "W" : "E";
|
||||
|
||||
const λn = Math.abs(lat);
|
||||
const φn = Math.abs(lon);
|
||||
|
||||
const λi = Math.trunc(λn);
|
||||
const φi = Math.trunc(φn);
|
||||
|
||||
const λf = λn - λi;
|
||||
const φf = φn - φi;
|
||||
|
||||
const λs = ((λf*3600)%60).toFixed(1);
|
||||
const φs = ((φf*3600)%60).toFixed(1);
|
||||
|
||||
const λm = Math.trunc(λf*60);
|
||||
const φm = Math.trunc(φf*60);
|
||||
|
||||
const λ =
|
||||
String(λi).padStart(2, "0") + "°" +
|
||||
String(λm).padStart(2, "0") + "'" +
|
||||
String(λs).padStart(4, "0") + '" ' +
|
||||
λh;
|
||||
|
||||
const φ =
|
||||
String(φi).padStart(3, "0") + "°" +
|
||||
String(φm).padStart(2, "0") + "'" +
|
||||
String(φs).padStart(4, "0") + '" ' +
|
||||
φh;
|
||||
|
||||
return λ+" "+φ;
|
||||
}
|
||||
|
||||
function geometryAsString (item, opts = {}) {
|
||||
const key = "key" in opts ? opts.key : "geometry";
|
||||
const formatDMS = opts.dms;
|
||||
|
||||
let str = "";
|
||||
|
||||
if (key in item) {
|
||||
const geometry = item[key];
|
||||
if (geometry && "coordinates" in geometry) {
|
||||
if (geometry.type == "Point") {
|
||||
if (formatDMS) {
|
||||
str = dms(geometry.coordinates[1], geometry.coordinates[0]);
|
||||
} else {
|
||||
str = `${geometry.coordinates[1].toFixed(6)}, ${geometry.coordinates[0].toFixed(6)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (str) {
|
||||
if (opts.url) {
|
||||
if (typeof opts.url === 'string') {
|
||||
str = `[${str}](${opts.url.replace("$x", geometry.coordinates[0]).replace("$y", geometry.coordinates[1])})`;
|
||||
} else {
|
||||
str = `[${str}](geo:${geometry.coordinates[0]},${geometry.coordinates[1]})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/** Extract preferences by prefix.
|
||||
*
|
||||
* This function returns a lambda which, given
|
||||
* a key or a prefix, extracts the relevant
|
||||
* preferences from the designated preferences
|
||||
* store.
|
||||
*
|
||||
* For instance, assume preferences = {
|
||||
* "a.b.c.d": 1,
|
||||
* "a.b.e.f": 2,
|
||||
* "g.h": 3
|
||||
* }
|
||||
*
|
||||
* And λ = preferencesλ(preferences). Then:
|
||||
*
|
||||
* λ("a.b") → { "a.b.c.d": 1, "a.b.e.f": 2 }
|
||||
* λ("a.b.e.f") → { "a.b.e.f": 2 }
|
||||
* λ("g.x", {"g.x.": 99}) → { "g.x.": 99 }
|
||||
* λ("a.c", {"g.x.": 99}) → { "g.x.": 99 }
|
||||
*
|
||||
* Note from the last two examples that a default value
|
||||
* may be provided and will be returned if a key does
|
||||
* not exist or is not searched for.
|
||||
*/
|
||||
function preferencesλ (preferences) {
|
||||
|
||||
return function (key, defaults={}) {
|
||||
const keys = Object.keys(preferences).filter(str => str.startsWith(key+".") || str == key);
|
||||
|
||||
const settings = {...defaults};
|
||||
for (const str of keys) {
|
||||
const k = str == key ? str : str.substring(key.length+1);
|
||||
const v = preferences[str];
|
||||
settings[k] = v;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
withParentProps,
|
||||
geometryAsString,
|
||||
preferencesλ
|
||||
}
|
||||
|
||||
@@ -5,14 +5,22 @@ import store from './store'
|
||||
import vuetify from './plugins/vuetify'
|
||||
import vueDebounce from 'vue-debounce'
|
||||
import { mapMutations } from 'vuex';
|
||||
|
||||
import rtc from './lib/rtc';
|
||||
|
||||
import { markdown, markdownInline } from './lib/markdown';
|
||||
import { geometryAsString } from './lib/utils';
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.use(vueDebounce);
|
||||
|
||||
Vue.filter('markdown', markdown);
|
||||
Vue.filter('markdownInline', markdownInline);
|
||||
Vue.filter('position', (str, item, opts) =>
|
||||
str
|
||||
.replace(/@POS(ITION)?@/g, geometryAsString(item, opts) || "(position unknown)")
|
||||
.replace(/@DMS@/g, geometryAsString(item, {...opts, dms:true}) || "(position unknown)")
|
||||
);
|
||||
// Vue.filter('position', (str, item, opts) => str.replace(/@POS(ITION)?@/, "☺"));
|
||||
|
||||
new Vue({
|
||||
data () {
|
||||
return {
|
||||
@@ -53,18 +61,12 @@ new Vue({
|
||||
|
||||
this.ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.rtc === true) {
|
||||
// Handle WebRTC message
|
||||
} else {
|
||||
this.setServerEvent(msg);
|
||||
}
|
||||
this.setServerEvent(msg);
|
||||
});
|
||||
|
||||
this.ws.addEventListener("open", (ev) => {
|
||||
console.log("WebSocket connection open", ev);
|
||||
this.setServerConnectionState(true);
|
||||
rtc.init(this.ws);
|
||||
rtc.talk();
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", (ev) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
import Home from '../views/Home.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Logout from '../views/Logout.vue'
|
||||
import Project from '../views/Project.vue'
|
||||
import ProjectList from '../views/ProjectList.vue'
|
||||
import ProjectSummary from '../views/ProjectSummary.vue'
|
||||
@@ -12,6 +14,7 @@ import SequenceSummary from '../views/SequenceSummary.vue'
|
||||
import Calendar from '../views/Calendar.vue'
|
||||
import Log from '../views/Log.vue'
|
||||
import QC from '../views/QC.vue'
|
||||
import Graphs from '../views/Graphs.vue'
|
||||
import Map from '../views/Map.vue'
|
||||
|
||||
|
||||
@@ -31,6 +34,40 @@ Vue.use(VueRouter)
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
|
||||
},
|
||||
{
|
||||
path: '/feed/:source',
|
||||
name: 'Feed',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (about.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "about" */ '../views/Feed.vue')
|
||||
},
|
||||
{
|
||||
path: "/settings/equipment",
|
||||
name: "equipment",
|
||||
component: () => import(/* webpackChunkName: "about" */ '../views/Equipment.vue')
|
||||
},
|
||||
{
|
||||
pathToRegexpOptions: { strict: true },
|
||||
path: "/login",
|
||||
redirect: "/login/"
|
||||
},
|
||||
{
|
||||
pathToRegexpOptions: { strict: true },
|
||||
name: "Login",
|
||||
path: "/login/",
|
||||
component: Login,
|
||||
meta: {
|
||||
// breadcrumbs: [
|
||||
// { text: "Projects", href: "/projects", disabled: true }
|
||||
// ]
|
||||
}
|
||||
},
|
||||
{
|
||||
// pathToRegexpOptions: { strict: true },
|
||||
path: "/logout",
|
||||
component: Logout,
|
||||
},
|
||||
{
|
||||
pathToRegexpOptions: { strict: true },
|
||||
path: "/projects",
|
||||
@@ -114,8 +151,19 @@ Vue.use(VueRouter)
|
||||
path: "qc",
|
||||
component: QC
|
||||
},
|
||||
{
|
||||
path: "graphs",
|
||||
component: Graphs,
|
||||
children: [
|
||||
{ path: "sequence/:sequence", name: "graphsBySequence" },
|
||||
{ path: "sequence/:sequence0/:sequence1", name: "graphsBySequences" },
|
||||
{ path: "date/:date0", name: "graphsByDate" },
|
||||
{ path: "date/:date0/:date1", name: "graphsByDates" }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "map",
|
||||
name: "map",
|
||||
component: Map
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
import api from './modules/api'
|
||||
import user from './modules/user'
|
||||
import snack from './modules/snack'
|
||||
import project from './modules/project'
|
||||
import notify from './modules/notify'
|
||||
@@ -11,6 +12,7 @@ Vue.use(Vuex)
|
||||
export default new Vuex.Store({
|
||||
modules: {
|
||||
api,
|
||||
user,
|
||||
snack,
|
||||
project,
|
||||
notify
|
||||
|
||||
@@ -13,13 +13,17 @@ async function api ({state, commit, dispatch}, [resource, init = {}, cb]) {
|
||||
init.body = JSON.stringify(init.body);
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${state.apiUrl}${resource}`, init);
|
||||
const url = /^https?:\/\//i.test(resource) ? resource : (state.apiUrl + resource);
|
||||
const res = await fetch(url, init);
|
||||
if (typeof cb === 'function') {
|
||||
cb(null, res);
|
||||
}
|
||||
if (res.ok) {
|
||||
|
||||
await dispatch('setCredentials');
|
||||
|
||||
try {
|
||||
return await res.json();
|
||||
return init.text ? (await res.text()) : (await res.json());
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
if (Number(res.headers.get("Content-Length")) === 0) {
|
||||
|
||||
104
lib/www/client/source/src/store/modules/user/actions.js
Normal file
104
lib/www/client/source/src/store/modules/user/actions.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import jwt_decode from 'jwt-decode';
|
||||
|
||||
async function login ({commit, dispatch}, loginRequest) {
|
||||
const url = "/login";
|
||||
const init = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: loginRequest
|
||||
}
|
||||
const res = await dispatch('api', [url, init]);
|
||||
if (res && res.ok) {
|
||||
await dispatch('setCredentials', true);
|
||||
await dispatch('loadUserPreferences');
|
||||
}
|
||||
}
|
||||
|
||||
async function logout ({commit, dispatch}) {
|
||||
commit('setCookie', null);
|
||||
commit('setUser', null);
|
||||
// Should delete JWT cookie
|
||||
await dispatch('api', ["/logout"]);
|
||||
|
||||
// Clear preferences
|
||||
commit('setPreferences', {});
|
||||
}
|
||||
|
||||
function browserCookie (state) {
|
||||
return document.cookie.split(/; */).find(i => /^JWT=.+/.test(i));
|
||||
}
|
||||
|
||||
function cookieChanged (cookie) {
|
||||
return browserCookie != cookie;
|
||||
}
|
||||
|
||||
function setCredentials ({state, commit, getters, dispatch}, force = false) {
|
||||
if (cookieChanged(state.cookie) || force) {
|
||||
try {
|
||||
const cookie = browserCookie();
|
||||
const decoded = cookie ? jwt_decode(cookie.split("=")[1]) : null;
|
||||
commit('setCookie', cookie);
|
||||
commit('setUser', decoded);
|
||||
} catch (err) {
|
||||
if (err.name == "InvalidTokenError") {
|
||||
console.warn("Failed to decode", browserCookie());
|
||||
} else {
|
||||
console.error("setCredentials", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch('loadUserPreferences');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user preferences to localStorage and store.
|
||||
*
|
||||
* User preferences are identified by a key that gets
|
||||
* prefixed with the user name and role. The value can
|
||||
* be anything that JSON.stringify can parse.
|
||||
*/
|
||||
function saveUserPreference ({state, commit}, [key, value]) {
|
||||
const k = `${state.user?.name}.${state.user?.role}.${key}`;
|
||||
|
||||
if (value !== undefined) {
|
||||
localStorage.setItem(k, JSON.stringify(value));
|
||||
|
||||
const preferences = state.preferences;
|
||||
preferences[key] = value;
|
||||
commit('setPreferences', preferences);
|
||||
} else {
|
||||
localStorage.removeItem(k);
|
||||
|
||||
const preferences = state.preferences;
|
||||
delete preferences[key];
|
||||
commit('setPreferences', preferences);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserPreferences ({state, commit}) {
|
||||
// Get all keys which are of interest to us
|
||||
const prefix = `${state.user?.name}.${state.user?.role}`;
|
||||
const keys = Object.keys(localStorage).filter( k => k.startsWith(prefix) );
|
||||
|
||||
// Build the preferences object
|
||||
const preferences = {};
|
||||
keys.map(str => {
|
||||
const value = JSON.parse(localStorage.getItem(str));
|
||||
const key = str.split(".").slice(2).join(".");
|
||||
preferences[key] = value;
|
||||
});
|
||||
|
||||
// Commit it
|
||||
commit('setPreferences', preferences);
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
login,
|
||||
logout,
|
||||
setCredentials,
|
||||
saveUserPreference,
|
||||
loadUserPreferences
|
||||
};
|
||||
18
lib/www/client/source/src/store/modules/user/getters.js
Normal file
18
lib/www/client/source/src/store/modules/user/getters.js
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
function user (state) {
|
||||
return state.user;
|
||||
}
|
||||
|
||||
function writeaccess (state) {
|
||||
return state.user && ["user", "admin"].includes(state.user.role);
|
||||
}
|
||||
|
||||
function adminaccess (state) {
|
||||
return state.user && state.user.role == "admin";
|
||||
}
|
||||
|
||||
function preferences (state) {
|
||||
return state.preferences;
|
||||
}
|
||||
|
||||
export default { user, writeaccess, adminaccess, preferences };
|
||||
6
lib/www/client/source/src/store/modules/user/index.js
Normal file
6
lib/www/client/source/src/store/modules/user/index.js
Normal 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 };
|
||||
14
lib/www/client/source/src/store/modules/user/mutations.js
Normal file
14
lib/www/client/source/src/store/modules/user/mutations.js
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
function setCookie (state, cookie) {
|
||||
state.cookie = cookie;
|
||||
}
|
||||
|
||||
function setUser (state, user) {
|
||||
state.user = user;
|
||||
}
|
||||
|
||||
function setPreferences (state, preferences) {
|
||||
state.preferences = preferences;
|
||||
}
|
||||
|
||||
export default { setCookie, setUser, setPreferences };
|
||||
7
lib/www/client/source/src/store/modules/user/state.js
Normal file
7
lib/www/client/source/src/store/modules/user/state.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const state = () => ({
|
||||
cookie: null,
|
||||
user: null,
|
||||
preferences: {}
|
||||
});
|
||||
|
||||
export default state;
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-sheet height="64">
|
||||
<v-toolbar flat color="white">
|
||||
<v-toolbar flat>
|
||||
|
||||
<v-menu bottom right>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
|
||||
513
lib/www/client/source/src/views/Equipment.vue
Normal file
513
lib/www/client/source/src/views/Equipment.vue
Normal file
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-dialog
|
||||
max-width="600px"
|
||||
:value="dialog"
|
||||
@input="closeDialog"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn v-if="writeaccess"
|
||||
small
|
||||
color="primary"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>Add</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-title v-if="dialogMode=='new'">Add new item</v-card-title>
|
||||
<v-card-title v-else>Edit item</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
label="Kind"
|
||||
required
|
||||
v-model="item.kind"
|
||||
:disabled="dialogMode == 'edit'"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
class="markdown"
|
||||
label="Description"
|
||||
dense
|
||||
auto-grow
|
||||
rows="1"
|
||||
v-model="item.description"
|
||||
>
|
||||
</v-textarea>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
label="Date"
|
||||
type="date"
|
||||
step="1"
|
||||
v-model="item.date"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
label="Time"
|
||||
type="time"
|
||||
step="60"
|
||||
v-model="item.time"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<template v-for="(attr, idx) in item.attributes">
|
||||
<v-col cols="4">
|
||||
<v-text-field
|
||||
label="Attribute"
|
||||
v-model="attr.key"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="8">
|
||||
<v-textarea
|
||||
label="Value"
|
||||
class="markdown"
|
||||
auto-grow
|
||||
rows="1"
|
||||
v-model="attr.value"
|
||||
>
|
||||
<template v-slot:append-outer>
|
||||
<v-btn
|
||||
fab
|
||||
x-small
|
||||
dark
|
||||
color="red"
|
||||
title="Remove this attribute / value pair"
|
||||
@click="removeAttribute(idx)"
|
||||
>
|
||||
<v-icon>mdi-minus</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-textarea>
|
||||
</v-col>
|
||||
</template>
|
||||
<v-col cols="12" class="text-right">
|
||||
<v-btn
|
||||
fab
|
||||
x-small
|
||||
color="primary"
|
||||
title="Add a new attribute / value pair to further describe the equipment"
|
||||
@click="addAttribute"
|
||||
>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="warning"
|
||||
@click="closeDialog"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="success"
|
||||
:loading="loading"
|
||||
:disabled="!canSave || loading"
|
||||
@click="saveItem"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="4">
|
||||
<v-toolbar
|
||||
dense
|
||||
flat
|
||||
>
|
||||
<v-toolbar-title>
|
||||
Equipment
|
||||
</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<v-list dense two-line>
|
||||
<v-subheader v-if="!latest.length">
|
||||
There are no items of equipment
|
||||
</v-subheader>
|
||||
<v-list-item-group
|
||||
v-model="selectedIndex"
|
||||
color="primary"
|
||||
>
|
||||
<v-list-item v-for="(item, idx) in latest" :key="idx">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{item.kind}}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
Last updated: {{item.tstamp.substring(0,16)}}Z
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list-item-group>
|
||||
</v-list>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="8">
|
||||
<v-card v-if="selectedItem">
|
||||
<v-card-title>{{selectedItem.kind}}</v-card-title>
|
||||
<v-card-subtitle class="text-caption">{{selectedItem.tstamp}}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<div v-html="$options.filters.markdown(selectedItem.description||'')"></div>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-simple-table>
|
||||
<template v-slot:default>
|
||||
<tbody>
|
||||
<tr v-for="(attr, idx) in selectedItem.attributes" :key="idx">
|
||||
<td>{{attr.key}}</td>
|
||||
<td v-html="$options.filters.markdown(attr.value||'')"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn v-if="writeaccess"
|
||||
small
|
||||
text
|
||||
color="primary"
|
||||
title="Make a change to this item"
|
||||
@click="editItem(selectedItem)"
|
||||
>
|
||||
Update
|
||||
</v-btn>
|
||||
<v-btn-toggle
|
||||
group
|
||||
v-model="historyMode"
|
||||
>
|
||||
<v-btn
|
||||
small
|
||||
text
|
||||
:disabled="false"
|
||||
title="View item's full history of changes"
|
||||
>
|
||||
History
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="writeaccess"
|
||||
small
|
||||
dark
|
||||
color="red"
|
||||
title="Remove this instance from the item's history"
|
||||
@click="confirmDelete(selectedItem)"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<v-subheader v-else-if="latest.length" class="justify-center">Select an item from the list</v-subheader>
|
||||
|
||||
<v-expand-transition v-if="selectedItem">
|
||||
<div v-if="historyMode===0">
|
||||
<v-subheader v-if="!selectedItemHistory || !selectedItemHistory.length"
|
||||
class="justify-center"
|
||||
>No more history</v-subheader>
|
||||
<v-card v-for="item in selectedItemHistory" class="mt-5">
|
||||
<v-card-title>{{selectedItem.kind}}</v-card-title>
|
||||
<v-card-subtitle class="text-caption">{{item.tstamp}}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<div v-html="$options.filters.markdown(item.description||'')"></div>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-simple-table>
|
||||
<template v-slot:default>
|
||||
<tbody>
|
||||
<tr v-for="(attr, idx) in item.attributes" :key="idx">
|
||||
<td>{{attr.key}}</td>
|
||||
<td v-html="$options.filters.markdown(attr.value||'')"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="writeaccess"
|
||||
small
|
||||
dark
|
||||
color="red"
|
||||
title="Remove this instance from the item's history"
|
||||
@click="confirmDelete(item)"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
<v-dialog
|
||||
:value="confirm.message"
|
||||
max-width="500px"
|
||||
persistent
|
||||
>
|
||||
<v-sheet
|
||||
class="px-7 pt-7 pb-4 mx-auto text-center d-inline-block"
|
||||
color="blue-grey darken-3"
|
||||
dark
|
||||
>
|
||||
<div class="grey--text text--lighten-1 text-body-2 mb-4" v-html="confirm.message"></div>
|
||||
|
||||
<v-btn
|
||||
:disabled="loading"
|
||||
class="ma-1"
|
||||
color="grey"
|
||||
plain
|
||||
@click="cancelConfirmAction"
|
||||
>
|
||||
{{ confirm.no || "Cancel" }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
:loading="loading"
|
||||
class="ma-1"
|
||||
color="error"
|
||||
plain
|
||||
@click="doConfirmAction"
|
||||
>
|
||||
{{ confirm.yes || "Delete" }}
|
||||
</v-btn>
|
||||
</v-sheet>
|
||||
</v-dialog>
|
||||
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: "Equipment",
|
||||
|
||||
data () {
|
||||
return {
|
||||
latest: [],
|
||||
all: [],
|
||||
item: {
|
||||
kind: null,
|
||||
description: null,
|
||||
tstamp: null,
|
||||
date: null,
|
||||
time: null,
|
||||
attributes: []
|
||||
},
|
||||
dialogMode: null,
|
||||
selectedIndex: null,
|
||||
historyMode: false,
|
||||
confirm: {
|
||||
message: null,
|
||||
action: null,
|
||||
yes: null,
|
||||
no: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
dialog (newVal, oldVal) {
|
||||
if (newVal) {
|
||||
const tstamp = new Date();
|
||||
this.item.date = tstamp.toISOString().substr(0, 10);
|
||||
this.item.time = tstamp.toISOString().substr(11, 5);
|
||||
}
|
||||
},
|
||||
|
||||
"item.date": function (newVal) {
|
||||
if (newVal) {
|
||||
this.item.tstamp = new Date(this.item.date+"T"+this.item.time);
|
||||
}
|
||||
},
|
||||
|
||||
"item.time": function (newVal) {
|
||||
if (newVal) {
|
||||
this.item.tstamp = new Date(this.item.date+"T"+this.item.time);
|
||||
}
|
||||
},
|
||||
|
||||
async serverEvent (event) {
|
||||
if (event.payload.schema == "public") {
|
||||
if (event.channel == "info") {
|
||||
if (!this.loading) {
|
||||
this.getEquipment();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
dialog () {
|
||||
return !!this.dialogMode;
|
||||
},
|
||||
|
||||
canSave () {
|
||||
return this.item.kind &&
|
||||
this.item.date && this.item.time &&
|
||||
(this.item.attributes.length
|
||||
? this.item.attributes.every(i => i.key && i.value)
|
||||
: (this.item.description ||"").trim());
|
||||
},
|
||||
|
||||
selectedItem () {
|
||||
return this.selectedIndex !== null
|
||||
? this.latest[this.selectedIndex]
|
||||
: null;
|
||||
},
|
||||
|
||||
selectedItemHistory () {
|
||||
if (this.selectedItem && this.historyMode === 0) {
|
||||
const items = this.all
|
||||
.filter(i => i.kind == this.selectedItem.kind && i.tstamp != this.selectedItem.tstamp)
|
||||
.sort( (a, b) => new Date(b.tstamp) - new Date(a.tstamp) );
|
||||
return items;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
async cancelConfirmAction () {
|
||||
this.confirm.action = null;
|
||||
this.confirm.message = null;
|
||||
this.confirm.yes = null;
|
||||
this.confirm.no = null;
|
||||
},
|
||||
|
||||
async doConfirmAction () {
|
||||
await this.confirm.action();
|
||||
this.cancelConfirmAction();
|
||||
},
|
||||
|
||||
async getEquipment () {
|
||||
const url = `/info/equipment`;
|
||||
|
||||
const items = await this.api([url]) || [];
|
||||
this.all = [...items];
|
||||
this.latest = this.all.filter(i =>
|
||||
!this.all.find(j => i.kind == j.kind && i.tstamp < j.tstamp)
|
||||
)
|
||||
.sort( (a, b) => a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0 );
|
||||
},
|
||||
|
||||
addAttribute () {
|
||||
this.item.attributes.push({key: undefined, value: undefined});
|
||||
},
|
||||
|
||||
removeAttribute (idx) {
|
||||
this.item.attributes.splice(idx, 1);
|
||||
},
|
||||
|
||||
async deleteItem (item) {
|
||||
const idx = this.all.findIndex(i => i.kind == item.kind && i.tstamp == item.tstamp);
|
||||
if (idx == -1) {
|
||||
return;
|
||||
}
|
||||
const url = `/info/equipment/${idx}`;
|
||||
const init = {
|
||||
method: "DELETE"
|
||||
};
|
||||
await this.api([url, init]);
|
||||
await this.getEquipment();
|
||||
},
|
||||
|
||||
confirmDelete (item) {
|
||||
this.confirm.action = () => this.deleteItem(item);
|
||||
this.confirm.message = "Are you sure? <b>This action is irreversible.</b>";
|
||||
},
|
||||
|
||||
clearItem () {
|
||||
this.item.kind = null;
|
||||
this.item.description = null;
|
||||
this.item.date = null;
|
||||
this.item.time = null;
|
||||
this.item.attributes = [];
|
||||
},
|
||||
|
||||
editItem (item) {
|
||||
this.item.kind = item.kind;
|
||||
this.item.description = item.description;
|
||||
this.item.tstamp = new Date();
|
||||
this.item.attributes = [...item.attributes];
|
||||
this.dialogMode = "edit";
|
||||
this.dialog = true;
|
||||
},
|
||||
|
||||
async saveItem () {
|
||||
const item = {};
|
||||
item.kind = this.item.kind;
|
||||
item.description = this.item.description;
|
||||
item.tstamp = this.item.tstamp.toISOString();
|
||||
item.attributes = [...this.item.attributes.filter(i => i.key && i.value)];
|
||||
if (this.dialogMode == "edit") {
|
||||
this.latest.splice(this.selectedIndex, 1, item);
|
||||
} else {
|
||||
this.latest.push(item);
|
||||
}
|
||||
|
||||
const url = `/info/equipment`;
|
||||
const init = {
|
||||
method: "POST",
|
||||
body: item
|
||||
};
|
||||
await this.api([url, init]);
|
||||
|
||||
this.closeDialog();
|
||||
await this.getEquipment();
|
||||
},
|
||||
|
||||
clearItem () {
|
||||
this.item.kind = null;
|
||||
this.item.description = null;
|
||||
this.item.attributes = [];
|
||||
this.item.tstamp = null;
|
||||
},
|
||||
|
||||
closeDialog (state = false) {
|
||||
this.clearItem();
|
||||
this.dialogMode = state===true ? "new" : null;
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
await this.getEquipment();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
118
lib/www/client/source/src/views/Feed.vue
Normal file
118
lib/www/client/source/src/views/Feed.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<v-overlay absolute opacity="1" :value="!feed.updated">
|
||||
<v-progress-circular indeterminate size="64"></v-progress-circular>
|
||||
</v-overlay>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{feed.title}}
|
||||
<a :href="feed.link"><v-icon class="ml-2">mdi-link</v-icon></a>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>Last updated: {{feed.updated}}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-timeline align-top dense>
|
||||
<v-timeline-item v-for="item in feed.items" small :key="item.id">
|
||||
<v-card :color="item.colour">
|
||||
<v-card-title primary-title>
|
||||
<div class="headline">{{item.title}}</div>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>{{item.updated}} ({{item.author}})</v-card-subtitle>
|
||||
<v-card-text v-html="item.summary">
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn target="_new" :href="item.link">Complete story</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: "FeedViewer",
|
||||
|
||||
data () {
|
||||
return {
|
||||
timer: null,
|
||||
feed: {}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
parse (text) {
|
||||
const data = {items:[]};
|
||||
const parser = new DOMParser();
|
||||
const xml = parser.parseFromString(text, "application/xml");
|
||||
const feed = xml.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "feed")[0];
|
||||
const entries = feed.getElementsByTagName("entry");
|
||||
|
||||
data.title = feed.getElementsByTagName("title")[0].childNodes[0].textContent;
|
||||
data.updated = feed.getElementsByTagName("updated")[0].childNodes[0].textContent;
|
||||
data.link = [...feed.getElementsByTagName("link")].filter(i =>
|
||||
i.getAttribute("type") == "text/html"
|
||||
).pop().getAttribute("href");
|
||||
|
||||
data.items = [...entries].map(entry => {
|
||||
const item = {};
|
||||
const link = entry.getElementsByTagName("link")[0];
|
||||
if (link) {
|
||||
item.link = link.getAttribute("href");
|
||||
}
|
||||
const author = entry.getElementsByTagName("author")[0];
|
||||
if (author) {
|
||||
const name = author.getElementsByTagName("name")[0];
|
||||
item.author = name ? name.childNodes[0].textContent : author.innerHTML;
|
||||
}
|
||||
const summaries = entry.getElementsByTagName("summary");
|
||||
const summary = [...summaries].find(i => i.getAttribute("type") == "xhtml") || summaries[0];
|
||||
|
||||
item.summary = summary.innerHTML;
|
||||
item.id = entry.getElementsByTagName("id")[0].childNodes[0].textContent;
|
||||
item.title = entry.getElementsByTagName("title")[0].childNodes[0].textContent;
|
||||
item.updated = entry.getElementsByTagName("updated")[0].childNodes[0].textContent;
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Try to fix idiosyncrasies and XML bugs in the source.
|
||||
*/
|
||||
fixText (text) {
|
||||
// Of course this will fail if there happens to be an </hr>
|
||||
// element in the source.
|
||||
return text.replace(/(<hr( [^>]*)?>)/g, "$1</hr>")
|
||||
},
|
||||
|
||||
async refresh () {
|
||||
const text = await this.api([`/rss/?remote=${atob(this.$route.params.source)}`, {text:true}]);
|
||||
try {
|
||||
this.feed = this.parse(text);
|
||||
} catch (err) {
|
||||
// If it failed to parse, try once again with some
|
||||
// tweaks to address known feed bugs.
|
||||
this.feed = this.parse(this.fixText(text));
|
||||
}
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
await this.refresh();
|
||||
this.timer = setInterval(this.refresh, 300000);
|
||||
},
|
||||
|
||||
unmounted () {
|
||||
cancelInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
331
lib/www/client/source/src/views/Graphs.vue
Normal file
331
lib/www/client/source/src/views/Graphs.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<v-card>
|
||||
|
||||
<v-toolbar v-if="$route.params.sequence" class="fixed">
|
||||
|
||||
<v-toolbar-title>
|
||||
Sequence {{$route.params.sequence}}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<dougal-graph-settings-sequence :aspects="aspects" @update:aspects="configure">
|
||||
</dougal-graph-settings-sequence>
|
||||
|
||||
<v-btn icon
|
||||
:disabled="!($route.params.sequence > firstSequence)"
|
||||
:to="{name: 'graphsBySequence', params: { sequence: firstSequence }}"
|
||||
title="Go to the first sequence"
|
||||
>
|
||||
<v-icon>mdi-skip-backward</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon
|
||||
:disabled="!prevSequence"
|
||||
:to="{name: 'graphsBySequence', params: { sequence: prevSequence }}"
|
||||
title="Go to the previous sequence"
|
||||
>
|
||||
<v-icon>mdi-skip-previous</v-icon>
|
||||
</v-btn>
|
||||
<v-menu
|
||||
:close-on-content-click="false"
|
||||
:disabled="!sequences.length"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn icon v-bind="attrs" v-on="on" :disabled="!sequences.length" title="Jump to sequence…">
|
||||
<v-icon>mdi-debug-step-over</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-autocomplete
|
||||
:value="$route.params.sequence*1"
|
||||
:items="sequences"
|
||||
item-text="sequence"
|
||||
item-value="sequence"
|
||||
@change="(sequence) => $router.push({name: 'graphsBySequence', params: {sequence}})"
|
||||
>
|
||||
</v-autocomplete>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn icon
|
||||
:disabled="!nextSequence"
|
||||
:to="{name: 'graphsBySequence', params: { sequence: nextSequence }}"
|
||||
title="Go to the next sequence"
|
||||
>
|
||||
<v-icon>mdi-skip-next</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon
|
||||
:disabled="!($route.params.sequence < lastSequence)"
|
||||
:to="{name: 'graphsBySequence', params: { sequence: lastSequence }}"
|
||||
title="Go to the last sequence"
|
||||
>
|
||||
<v-icon>mdi-skip-forward</v-icon>
|
||||
</v-btn>
|
||||
|
||||
</v-toolbar>
|
||||
|
||||
<v-toolbar v-else-if="$route.params.sequence0">
|
||||
|
||||
<v-toolbar-title>
|
||||
Sequences {{$route.params.sequence0}}‒{{$route.params.sequence1}}
|
||||
</v-toolbar-title>
|
||||
|
||||
</v-toolbar>
|
||||
|
||||
<v-toolbar v-else-if="$route.params.date">
|
||||
|
||||
<v-toolbar-title>
|
||||
Date {{$route.params.date}}
|
||||
</v-toolbar-title>
|
||||
|
||||
</v-toolbar>
|
||||
|
||||
<v-toolbar v-else-if="$route.params.date0">
|
||||
|
||||
<v-toolbar-title>
|
||||
Dates {{$route.params.date0}}‒{{$route.params.date1}}
|
||||
</v-toolbar-title>
|
||||
|
||||
</v-toolbar>
|
||||
|
||||
|
||||
<v-toolbar flat>
|
||||
<!--
|
||||
This is a “ghost” toolbar so that elements further down in the page are
|
||||
not hidden behind the (now position: fixed) real toolbar.
|
||||
-->
|
||||
</v-toolbar>
|
||||
|
||||
<v-container>
|
||||
<v-row v-for="(item, idx) in visibleItems" :key="idx">
|
||||
<v-col>
|
||||
<component
|
||||
:is="item.component"
|
||||
:data="attributesFor(item)"
|
||||
:settings="preferencesFor(item.component)"
|
||||
@update:settings="configure">
|
||||
</component>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.v-toolbar.fixed {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 1px dashed lightgray;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { preferencesλ } from '@/lib/utils.js';
|
||||
import DougalGraphGunsPressure from '@/components/graph-guns-pressure.vue';
|
||||
import DougalGraphGunsTiming from '@/components/graph-guns-timing.vue';
|
||||
import DougalGraphGunsDepth from '@/components/graph-guns-depth.vue';
|
||||
import DougalGraphGunsHeatmap from '@/components/graph-guns-heatmap.vue';
|
||||
import DougalGraphArraysIJScatter from '@/components/graph-arrays-ij-scatter.vue';
|
||||
import DougalGraphSettingsSequence from '@/components/graph-settings-sequence.vue';
|
||||
|
||||
export default {
|
||||
name: "Graphs",
|
||||
|
||||
components: {
|
||||
DougalGraphSettingsSequence,
|
||||
DougalGraphArraysIJScatter,
|
||||
DougalGraphGunsPressure,
|
||||
DougalGraphGunsTiming,
|
||||
DougalGraphGunsDepth,
|
||||
DougalGraphGunsHeatmap
|
||||
},
|
||||
|
||||
data () {
|
||||
const items = [
|
||||
{
|
||||
component: "DougalGraphGunsPressure",
|
||||
},
|
||||
{
|
||||
component: "DougalGraphGunsTiming",
|
||||
},
|
||||
{
|
||||
component: "DougalGraphGunsDepth",
|
||||
},
|
||||
{
|
||||
component: "DougalGraphGunsHeatmap",
|
||||
},
|
||||
{
|
||||
component: "DougalGraphArraysIJScatter",
|
||||
attributes: {
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
items,
|
||||
data: null,
|
||||
sequences: [],
|
||||
jumpToSequence: null,
|
||||
aspects: items.map(i => i.component)
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
preferences () {
|
||||
this.configure(preferencesλ(this.preferences)(this.$options.name, {aspects: this.aspects}))
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
getRows() {
|
||||
return Array(this.rows).fill().map( (el, idx) => idx );
|
||||
},
|
||||
|
||||
getCols () {
|
||||
return Array(this.cols).fill().map( (el, idx) => idx );
|
||||
},
|
||||
|
||||
visibleItems () {
|
||||
return this.items.filter( i => this.aspects.includes(i.component) );
|
||||
},
|
||||
|
||||
firstSequence () {
|
||||
return this.sequences[this.sequences.length-1]?.sequence;
|
||||
},
|
||||
|
||||
prevSequence () {
|
||||
const seq = Number(this.$route.params.sequence);
|
||||
const val = this.sequences
|
||||
.filter(i => i.sequence < seq)
|
||||
.map(i => i.sequence)
|
||||
.reduce( (acc, cur) => Math.max(acc, cur), -Infinity);
|
||||
|
||||
return isFinite(val) ? val : undefined;
|
||||
},
|
||||
|
||||
nextSequence () {
|
||||
const seq = Number(this.$route.params.sequence);
|
||||
const val = this.sequences
|
||||
.filter(i => i.sequence > seq)
|
||||
.map(i => i.sequence)
|
||||
.reduce( (acc, cur) => Math.min(acc, cur), +Infinity);
|
||||
|
||||
return isFinite(val) ? val : undefined;
|
||||
},
|
||||
|
||||
lastSequence () {
|
||||
return this.sequences[0]?.sequence;
|
||||
},
|
||||
|
||||
...mapGetters(['user', 'preferences', 'writeaccess', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
configure (data) {
|
||||
if ("aspects" in data) {
|
||||
this.aspects = [...data.aspects];
|
||||
}
|
||||
for (const key in data) {
|
||||
this.saveUserPreference([`${this.$options.name}.${key}`, data[key]]);
|
||||
}
|
||||
},
|
||||
|
||||
attributesFor (item) {
|
||||
return this.data
|
||||
? Object.assign({
|
||||
items: this.data,
|
||||
meta: {...this.$route.params}
|
||||
}, item?.attributes)
|
||||
: null;
|
||||
},
|
||||
|
||||
preferencesFor (key, defaults) {
|
||||
return preferencesλ(this.preferences)(`${this.$options.name}.${key}`, defaults);
|
||||
},
|
||||
|
||||
gotoSequence(seq) {
|
||||
this.$route.params.sequence = seq;
|
||||
},
|
||||
|
||||
...mapActions(["api", "showSnack", "saveUserPreference"])
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.data = null;
|
||||
console.log("beforeRouteLeave");
|
||||
next();
|
||||
},
|
||||
|
||||
async beforeRouteUpdate (to, from, next) {
|
||||
console.log("beforeRouteUpdate");
|
||||
this.data = null;
|
||||
next();
|
||||
|
||||
const url = `/project/${this.$route.params.project}/sequence/${this.$route.params.sequence}?project=sequence,point,tstamp,geometrypreplot,errorraw,errorfinal,meta&path=$.raw.smsrc`;
|
||||
this.data = Object.freeze(await this.api([url]));
|
||||
this.sequences = await this.api([`/project/${this.$route.params.project}/sequence`]);
|
||||
},
|
||||
|
||||
async beforeRouteEnter (to, from, next) {
|
||||
console.log("beforeRouteEnter enter");
|
||||
|
||||
next( async vm => {
|
||||
if (vm.$route.params.sequence) {
|
||||
const url = `/project/${vm.$route.params.project}/sequence/${vm.$route.params.sequence}?project=sequence,point,tstamp,geometrypreplot,errorraw,errorfinal,meta&path=$.raw.smsrc`;
|
||||
|
||||
vm.data = null;
|
||||
vm.api([url]).then( d => vm.data = Object.freeze(d) );
|
||||
vm.api([`/project/${vm.$route.params.project}/sequence`]).then( d => vm.sequences = d );
|
||||
} else {
|
||||
// FIXME Ultra-dirty hack to get a result when navigating directly to ‘Graphs’
|
||||
if (!vm.sequences.length) {
|
||||
vm.sequences = await vm.api([`/project/${vm.$route.params.project}/sequence`]);
|
||||
}
|
||||
vm.$router.push({name: "graphsBySequence", params: {
|
||||
project: vm.$route.params.project,
|
||||
sequence: vm.sequences[0]?.sequence
|
||||
}});
|
||||
}
|
||||
|
||||
console.log("beforeRouteEnter exit");
|
||||
});
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
console.log("Graphs mounted");
|
||||
this.sequences = await this.api([`/project/${this.$route.params.project}/sequence`]);
|
||||
|
||||
if (!this.$route.params.sequence) {
|
||||
this.$router.push({name: "graphsBySequence", params: {
|
||||
project: this.$route.params.project,
|
||||
sequence: this.sequences[0]?.sequence
|
||||
}});
|
||||
}
|
||||
|
||||
const url = `/project/${this.$route.params.project}/sequence/${this.$route.params.sequence}?project=sequence,point,tstamp,geometrypreplot,errorraw,errorfinal,meta&path=$.raw.smsrc`;
|
||||
|
||||
this.data = Object.freeze(await this.api([url]));
|
||||
console.log("Mount finished");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<v-menu
|
||||
<v-menu v-if="writeaccess"
|
||||
v-model="contextMenuShow"
|
||||
:position-x="contextMenuX"
|
||||
:position-y="contextMenuY"
|
||||
@@ -24,23 +24,97 @@
|
||||
offset-y
|
||||
>
|
||||
<v-list dense v-if="contextMenuItem">
|
||||
<v-list-item @click="setNTBA">
|
||||
<v-list-item-title v-if="contextMenuItem.ntba">Unset NTBA</v-list-item-title>
|
||||
<v-list-item-title v-else>Set NTBA</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="addToPlan" v-if="!contextMenuItem.ntba">
|
||||
<v-list-item-title>Add to plan</v-list-item-title>
|
||||
<template v-if="!selectOn">
|
||||
<v-list-item @click="setNTBA" v-if="contextMenuItem.ntba || (contextMenuItem.num_points == contextMenuItem.na)">
|
||||
<v-list-item-title v-if="contextMenuItem.ntba"
|
||||
title="Mark the line as part of the acquisition plan"
|
||||
>Unset NTBA</v-list-item-title>
|
||||
<v-list-item-title v-else
|
||||
title="Mark the line as not to be acquired"
|
||||
>Set NTBA</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="setComplete" v-if="contextMenuItem.na && (contextMenuItem.num_points != contextMenuItem.na || contextMenuItem.tba != contextMenuItem.na)">
|
||||
<v-list-item-title v-if="contextMenuItem.tba != contextMenuItem.na"
|
||||
title="Mark any remaining points as pending acquisition"
|
||||
>Unset line complete</v-list-item-title>
|
||||
<v-list-item-title v-else
|
||||
title="Mark any remaining points as not to be acquired"
|
||||
>Set line complete</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="addToPlan" v-if="!contextMenuItem.ntba && !isPlanned(contextMenuItem)">
|
||||
<v-list-item-title>Add to plan</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="removeFromPlan" v-if="isPlanned(contextMenuItem)">
|
||||
<v-list-item-title>Remove from plan</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="showLineColourDialog">
|
||||
<v-list-item-title>Set colour…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-list-item @click="showLineColourDialog">
|
||||
<v-list-item-title>Set colour…</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-divider></v-divider>
|
||||
<v-list-item>
|
||||
<v-list-item-action-text>Multi-select</v-list-item-action-text>
|
||||
<v-list-item-action><v-checkbox v-model="selectOn"></v-checkbox></v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-dialog
|
||||
v-model="colourPickerShow"
|
||||
max-width="300"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>Choose line colour</v-card-title>
|
||||
<v-card-text>
|
||||
<v-color-picker
|
||||
v-model="selectedColour"
|
||||
show-swatches
|
||||
hide-canvas
|
||||
hide-inputs
|
||||
hide-mode-switch
|
||||
@update:color="selectedColour.alpha = 0.5"
|
||||
>
|
||||
</v-color-picker>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn small
|
||||
@click="selectedColour=null; setLineColour()"
|
||||
>
|
||||
Clear
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn small
|
||||
@click="setLineColour"
|
||||
>
|
||||
Set
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn small
|
||||
@click="colourPickerShow=false"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
item-key="line"
|
||||
:items-per-page.sync="itemsPerPage"
|
||||
:search="filter"
|
||||
:loading="loading"
|
||||
:fixed-header="true"
|
||||
:item-class="(item) => (activeItem == item && !edit) ? 'blue accent-1 elevation-3' : ''"
|
||||
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
|
||||
:item-class="itemClass"
|
||||
:show-select="selectOn"
|
||||
v-model="selectedRows"
|
||||
@click:row="setActiveItem"
|
||||
@contextmenu:row="contextMenu"
|
||||
>
|
||||
@@ -58,12 +132,16 @@
|
||||
</dougal-line-status>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.tba="{item, value}">
|
||||
<span :class="!value && (item.na ? 'warning--text' : 'success--text')">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.length="props">
|
||||
<span>{{ Math.round(props.value) }} m</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.azimuth="props">
|
||||
<span>{{ props.value.toFixed(1) }} °</span>
|
||||
<span>{{ props.value.toFixed(2) }} °</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.remarks="{item}">
|
||||
@@ -78,8 +156,8 @@
|
||||
>
|
||||
</v-text-field>
|
||||
<div v-else>
|
||||
{{item.remarks}}
|
||||
<v-btn v-if="edit === null"
|
||||
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
|
||||
<v-btn v-if="writeaccess && edit === null"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
@@ -112,7 +190,6 @@
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import DougalLineStatus from '@/components/line-status';
|
||||
|
||||
|
||||
export default {
|
||||
name: "LineList",
|
||||
|
||||
@@ -143,7 +220,17 @@ export default {
|
||||
},
|
||||
{
|
||||
value: "num_points",
|
||||
text: "Num. points",
|
||||
text: "Points",
|
||||
align: "end"
|
||||
},
|
||||
{
|
||||
value: "na",
|
||||
text: "Virgin",
|
||||
align: "end"
|
||||
},
|
||||
{
|
||||
value: "tba",
|
||||
text: "Remaining",
|
||||
align: "end"
|
||||
},
|
||||
{
|
||||
@@ -162,23 +249,31 @@ export default {
|
||||
}
|
||||
],
|
||||
items: [],
|
||||
selectOn: false,
|
||||
selectedRows: [],
|
||||
filter: null,
|
||||
num_lines: null,
|
||||
sequences: [],
|
||||
activeItem: null,
|
||||
edit: null, // {line, key, value}
|
||||
queuedReload: false,
|
||||
itemsPerPage: 25,
|
||||
|
||||
// Context menu stuff
|
||||
contextMenuShow: false,
|
||||
contextMenuX: 0,
|
||||
contextMenuY: 0,
|
||||
contextMenuItem: null
|
||||
contextMenuItem: null,
|
||||
|
||||
// Colour picker stuff
|
||||
colourPickerShow: false,
|
||||
selectedColour: null,
|
||||
styles: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['loading', 'serverEvent'])
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -202,15 +297,23 @@ export default {
|
||||
},
|
||||
|
||||
async serverEvent (event) {
|
||||
if (event.channel == "preplot_lines" && event.payload.pid == this.$route.params.project) {
|
||||
if (!this.loading && !this.queuedReload) {
|
||||
// Do not force a non-cached response if refreshing as a result
|
||||
// of an event notification. We will assume that the server has
|
||||
// already had time to update the cache by the time our request
|
||||
// gets back to it.
|
||||
this.getLines();
|
||||
} else {
|
||||
this.queuedReload = true;
|
||||
if (event.payload.pid == this.$route.params.project) {
|
||||
if (event.channel == "preplot_lines" || event.channel == "preplot_points") {
|
||||
if (!this.loading && !this.queuedReload) {
|
||||
// Do not force a non-cached response if refreshing as a result
|
||||
// of an event notification. We will assume that the server has
|
||||
// already had time to update the cache by the time our request
|
||||
// gets back to it.
|
||||
this.getLines();
|
||||
} else {
|
||||
this.queuedReload = true;
|
||||
}
|
||||
} else if ([ "planned_lines", "raw_lines", "final_lines" ].includes(event.channel)) {
|
||||
if (!this.loading && !this.queuedReload) {
|
||||
this.getSequences();
|
||||
} else {
|
||||
this.queuedReload = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -218,19 +321,47 @@ export default {
|
||||
queuedReload (newVal, oldVal) {
|
||||
if (newVal && !oldVal && !this.loading) {
|
||||
this.getLines();
|
||||
this.getSequences();
|
||||
}
|
||||
},
|
||||
|
||||
loading (newVal, oldVal) {
|
||||
if (!newVal && oldVal && this.queuedReload) {
|
||||
this.getLines();
|
||||
this.getSequences();
|
||||
}
|
||||
},
|
||||
|
||||
itemsPerPage (newVal, oldVal) {
|
||||
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
|
||||
},
|
||||
|
||||
user (newVal, oldVal) {
|
||||
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
itemClass (item) {
|
||||
|
||||
const colourClass = item.meta.colour ? "bg-clr-"+item.meta.colour.slice(1) : null;
|
||||
if (colourClass && ![...this.styles.cssRules].some(i => i.selectorText == "."+colourClass)) {
|
||||
const rule = `.${colourClass} { background-color: ${item.meta.colour}; }`;
|
||||
this.styles.insertRule(rule);
|
||||
}
|
||||
|
||||
return [
|
||||
item.meta.colour ? colourClass : "",
|
||||
(this.activeItem == item && !this.edit) ? 'blue accent-1 elevation-3' : ''
|
||||
];
|
||||
},
|
||||
|
||||
isPlanned(item) {
|
||||
return this.sequences.find(i => i.line == item.line && i.status == 'planned');
|
||||
},
|
||||
|
||||
contextMenu (e, {item}) {
|
||||
e.preventDefault();
|
||||
this.contextMenuShow = false;
|
||||
@@ -241,12 +372,21 @@ export default {
|
||||
},
|
||||
|
||||
setNTBA () {
|
||||
this.removeFromPlan();
|
||||
this.saveItem({
|
||||
line: this.contextMenuItem.line,
|
||||
key: 'ntba',
|
||||
value: !this.contextMenuItem.ntba
|
||||
})
|
||||
},
|
||||
|
||||
setComplete () {
|
||||
this.saveItem({
|
||||
line: this.contextMenuItem.line,
|
||||
key: 'complete',
|
||||
value: this.contextMenuItem.na && this.contextMenuItem.tba == this.contextMenuItem.na
|
||||
})
|
||||
},
|
||||
|
||||
async addToPlan () {
|
||||
const payload = {
|
||||
@@ -255,7 +395,6 @@ export default {
|
||||
fsp: this.contextMenuItem.fsp,
|
||||
lsp: this.contextMenuItem.lsp
|
||||
}
|
||||
console.log("Plan", payload);
|
||||
const url = `/project/${this.$route.params.project}/plan`;
|
||||
const init = {
|
||||
method: "POST",
|
||||
@@ -265,6 +404,42 @@ export default {
|
||||
await this.api([url, init]);
|
||||
},
|
||||
|
||||
async removeFromPlan () {
|
||||
const plannedLine = this.sequences.find(i => i.status == "planned" && i.line == this.contextMenuItem.line);
|
||||
if (plannedLine && plannedLine.sequence) {
|
||||
const url = `/project/${this.$route.params.project}/plan/${plannedLine.sequence}`;
|
||||
const init = {
|
||||
method: "DELETE"
|
||||
}
|
||||
await this.api([url, init]);
|
||||
}
|
||||
},
|
||||
|
||||
showLineColourDialog () {
|
||||
this.selectedColour = this.contextMenuItem.meta.colour
|
||||
? {hexa: this.contextMenuItem.meta.colour}
|
||||
: null;
|
||||
this.colourPickerShow = true;
|
||||
},
|
||||
|
||||
setLineColour () {
|
||||
const items = this.selectOn ? this.selectedRows : [ this.contextMenuItem ];
|
||||
const colour = this.selectedColour ? this.selectedColour.hex+"80" : null;
|
||||
|
||||
this.selectedRows = [];
|
||||
this.selectOn = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (colour) {
|
||||
item.meta.colour = colour;
|
||||
} else {
|
||||
delete item.meta.colour;
|
||||
}
|
||||
this.saveItem({line: item.line, key: "meta", value: item.meta});
|
||||
this.colourPickerShow = false;
|
||||
}
|
||||
},
|
||||
|
||||
editItem (item, key) {
|
||||
this.edit = {
|
||||
line: item.line,
|
||||
@@ -308,8 +483,13 @@ export default {
|
||||
},
|
||||
|
||||
async getSequences () {
|
||||
const url = `/project/${this.$route.params.project}/sequence`;
|
||||
this.sequences = await this.api([url]) || [];
|
||||
const urlS = `/project/${this.$route.params.project}/sequence`;
|
||||
this.sequences = await this.api([urlS]) || [];
|
||||
|
||||
const urlP = `/project/${this.$route.params.project}/plan`;
|
||||
const planned = await this.api([urlP]) || [];
|
||||
planned.forEach(i => i.status = "planned");
|
||||
this.sequences.push(...planned);
|
||||
},
|
||||
|
||||
setActiveItem (item) {
|
||||
@@ -325,6 +505,11 @@ export default {
|
||||
this.getLines();
|
||||
this.getNumLines();
|
||||
this.getSequences();
|
||||
|
||||
// Initialise stylesheet
|
||||
const el = document.createElement("style");
|
||||
document.head.appendChild(el);
|
||||
this.styles = document.styleSheets[document.styleSheets.length-1];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,20 +5,35 @@
|
||||
<v-card-title>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>
|
||||
{{
|
||||
$route.params.sequence
|
||||
? ($route.params.sequence.includes && $route.params.sequence.includes(";"))
|
||||
? `Sequences ${$route.params.sequence.split(";").sort().join(", ")}`
|
||||
: `Sequence ${$route.params.sequence}`
|
||||
: $route.params.date0
|
||||
? $route.params.date1
|
||||
? `Between ${$route.params.date0} and ${$route.params.date1}`
|
||||
: `On ${$route.params.date0}`
|
||||
: "All events"
|
||||
}}
|
||||
<span class="d-none d-lg-inline">
|
||||
{{
|
||||
$route.params.sequence
|
||||
? ($route.params.sequence.includes && $route.params.sequence.includes(";"))
|
||||
? `Sequences ${$route.params.sequence.split(";").sort().join(", ")}`
|
||||
: `Sequence ${$route.params.sequence}`
|
||||
: $route.params.date0
|
||||
? $route.params.date1
|
||||
? `Between ${$route.params.date0} and ${$route.params.date1}`
|
||||
: `On ${$route.params.date0}`
|
||||
: "All events"
|
||||
}}
|
||||
</span>
|
||||
<span class="d-lg-none">
|
||||
{{
|
||||
$route.params.sequence
|
||||
? ($route.params.sequence.includes && $route.params.sequence.includes(";"))
|
||||
? `${$route.params.sequence.split(";").sort().join(", ")}`
|
||||
: `${$route.params.sequence}`
|
||||
: $route.params.date0
|
||||
? $route.params.date1
|
||||
? `${$route.params.date0} ‒ ${$route.params.date1}`
|
||||
: `${$route.params.date0}`
|
||||
: ""
|
||||
}}
|
||||
</span>
|
||||
</v-toolbar-title>
|
||||
|
||||
<dougal-event-edit-dialog
|
||||
<dougal-event-edit-dialog v-if="writeaccess"
|
||||
v-model="eventDialog"
|
||||
:allowed-labels="userLabels"
|
||||
:preset-remarks="presetRemarks"
|
||||
@@ -28,6 +43,38 @@
|
||||
:event-mode="online?'seq':'timed'"
|
||||
@save="saveEvent"
|
||||
></dougal-event-edit-dialog>
|
||||
|
||||
<v-menu v-if="$route.params.sequence">
|
||||
<template v-slot:activator="{on, attrs}">
|
||||
<v-btn class="ml-5" small v-on="on" v-bind="attrs">
|
||||
<span class="d-none d-lg-inline">Download as…</span>
|
||||
<v-icon right small>mdi-cloud-download</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fvnd.seis%2Bjson`"
|
||||
title="Download as a Multiseis-compatible Seis+JSON file."
|
||||
>Seis+JSON</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fgeo%2Bjson`"
|
||||
title="Download as a QGIS-compatible GeoJSON file"
|
||||
>GeoJSON</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fjson`"
|
||||
title="Download as a generic JSON file"
|
||||
>JSON</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=text%2Fhtml`"
|
||||
title="Download as an HTML formatted file"
|
||||
>HTML</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${$route.params.sequence}?mime=application%2Fpdf`"
|
||||
title="Download as a Portable Document File"
|
||||
>PDF</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
@@ -43,6 +90,7 @@
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="rows"
|
||||
:items-per-page.sync="itemsPerPage"
|
||||
item-key="tstamp"
|
||||
sort-by="tstamp"
|
||||
:sort-desc="true"
|
||||
@@ -50,6 +98,7 @@
|
||||
:custom-filter="searchTable"
|
||||
:loading="loading"
|
||||
fixed-header
|
||||
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
|
||||
>
|
||||
|
||||
<template v-slot:item.tstamp="{value}">
|
||||
@@ -59,141 +108,146 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.remarks="{item}">
|
||||
<v-edit-dialog v-if="item.items"
|
||||
large
|
||||
@save="rowEditorSave"
|
||||
@cancel="rowEditorCancel"
|
||||
@open="rowEditorOpen(item)"
|
||||
@close="rowEditorClose"
|
||||
> <div v-html="item.items.map(i => i.remarks).join('<br/>')"></div>
|
||||
<template v-slot:input>
|
||||
<h3>{{
|
||||
editedRow.sequence
|
||||
? `${editedRow.sequence} @ ${editedRow.point}`
|
||||
: editedRow.tstamp
|
||||
? editedRow.tstamp.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2")
|
||||
: editedRow.key
|
||||
}}</h3><hr/>
|
||||
<template v-if="writeaccess">
|
||||
<v-edit-dialog v-if="item.items"
|
||||
large
|
||||
@save="rowEditorSave"
|
||||
@cancel="rowEditorCancel"
|
||||
@open="rowEditorOpen(item)"
|
||||
@close="rowEditorClose"
|
||||
> <div v-html="$options.filters.markdownInline(item.items.map(i => i.remarks).join('<br/>'))"></div>
|
||||
<template v-slot:input>
|
||||
<h3>{{
|
||||
editedRow.sequence
|
||||
? `${editedRow.sequence} @ ${editedRow.point}`
|
||||
: editedRow.tstamp
|
||||
? editedRow.tstamp.replace(/(.{10})T(.{8}).{4}Z$/, "$1 $2")
|
||||
: editedRow.key
|
||||
}}</h3><hr/>
|
||||
|
||||
|
||||
<dougal-context-menu
|
||||
:value="remarksMenu"
|
||||
@input="addPresetRemark"
|
||||
:items="presetRemarks"
|
||||
absolute
|
||||
></dougal-context-menu>
|
||||
<dougal-context-menu
|
||||
:value="remarksMenu"
|
||||
@input="addPresetRemark"
|
||||
:items="presetRemarks"
|
||||
absolute
|
||||
></dougal-context-menu>
|
||||
|
||||
|
||||
<template v-for="editedItem in editedRow.items">
|
||||
<template v-for="editedItem in editedRow.items">
|
||||
<v-text-field
|
||||
v-model="editedItem.remarks"
|
||||
label="Edit"
|
||||
single-line
|
||||
hide-details="auto"
|
||||
>
|
||||
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-show="!editedItem.remarks && presetRemarks"
|
||||
title="Select predefined comments"
|
||||
color="primary"
|
||||
@click="(e) => {remarksMenuItem = editedItem; remarksMenu = e}"
|
||||
>
|
||||
mdi-dots-vertical
|
||||
</v-icon>
|
||||
</template>
|
||||
|
||||
<template v-slot:append v-if="editedItem.remarks || editedItem.labels.filter(l => labels[l].model.user).length">
|
||||
<v-hover v-slot:default="{hover}">
|
||||
<v-icon
|
||||
title="Remove comment"
|
||||
:color="hover ? 'error' : 'error lighten-4'"
|
||||
@click="removeEvent(editedItem, editedRow)"
|
||||
>mdi-minus-circle</v-icon>
|
||||
</v-hover>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-container>
|
||||
<v-row no-gutters>
|
||||
<v-col class="flex-grow-0">
|
||||
<!-- Add a new label control -->
|
||||
<v-edit-dialog
|
||||
large
|
||||
@save="addLabel(editedItem)"
|
||||
@cancel="selectedLabels=[]"
|
||||
>
|
||||
<v-icon
|
||||
small
|
||||
title="Add label"
|
||||
>mdi-tag-plus</v-icon>
|
||||
<template v-slot:input>
|
||||
<v-autocomplete
|
||||
:items="availableLabels(editedItem.labels)"
|
||||
v-model="selectedLabels"
|
||||
label="Add label"
|
||||
chips
|
||||
deletable-chips
|
||||
multiple
|
||||
autofocus
|
||||
@keydown.stop="(e) => {if (e.key == 'Enter') debug(e)}"
|
||||
@input="labelSearch = null;"
|
||||
:search-input.sync="labelSearch"
|
||||
>
|
||||
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
v-bind="data.attrs"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
@click="data.select"
|
||||
:color="labels[data.item].view.colour"
|
||||
:title="labels[data.item].view.description"
|
||||
>{{data.item}}</v-chip>
|
||||
</template>
|
||||
|
||||
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
</v-col>
|
||||
<v-col class="flex-grow-0">
|
||||
<v-chip-group>
|
||||
<v-chip v-for="label in editedItem.labels" :key="label"
|
||||
small
|
||||
:close="labels[label].model.user"
|
||||
:color="labels[label].view.colour"
|
||||
:title="labels[label].view.description"
|
||||
@click:close="removeLabel(label, editedItem)"
|
||||
>{{label}}</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
</template>
|
||||
|
||||
<v-icon v-if="editedRow.items.length == 0 || editedRow.items[editedRow.items.length-1].remarks"
|
||||
color="primary"
|
||||
title="Add comment"
|
||||
class="mb-2"
|
||||
@click="addEvent"
|
||||
>mdi-plus-circle</v-icon>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<v-edit-dialog v-else
|
||||
@save="rowEditorSave"
|
||||
@cancel="rowEditorCancel"
|
||||
@open="rowEditorOpen"
|
||||
@close="rowEditorClose"
|
||||
>
|
||||
<template v-slot:input>
|
||||
<v-text-field
|
||||
v-model="editedItem.remarks"
|
||||
v-model="props.item.remarks[0]"
|
||||
label="Edit"
|
||||
single-line
|
||||
hide-details="auto"
|
||||
>
|
||||
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-show="!editedItem.remarks && presetRemarks"
|
||||
title="Select predefined comments"
|
||||
color="primary"
|
||||
@click="(e) => {remarksMenuItem = editedItem; remarksMenu = e}"
|
||||
>
|
||||
mdi-dots-vertical
|
||||
</v-icon>
|
||||
</template>
|
||||
|
||||
<template v-slot:append v-if="editedItem.remarks || editedItem.labels.filter(l => labels[l].model.user).length">
|
||||
<v-hover v-slot:default="{hover}">
|
||||
<v-icon
|
||||
title="Remove comment"
|
||||
:color="hover ? 'error' : 'error lighten-4'"
|
||||
@click="removeEvent(editedItem, editedRow)"
|
||||
>mdi-minus-circle</v-icon>
|
||||
</v-hover>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-container>
|
||||
<v-row no-gutters>
|
||||
<v-col class="flex-grow-0">
|
||||
<!-- Add a new label control -->
|
||||
<v-edit-dialog
|
||||
large
|
||||
@save="addLabel(editedItem)"
|
||||
@cancel="selectedLabels=[]"
|
||||
>
|
||||
<v-icon
|
||||
small
|
||||
title="Add label"
|
||||
>mdi-tag-plus</v-icon>
|
||||
<template v-slot:input>
|
||||
<v-autocomplete
|
||||
:items="availableLabels(editedItem.labels)"
|
||||
v-model="selectedLabels"
|
||||
label="Add label"
|
||||
chips
|
||||
deletable-chips
|
||||
multiple
|
||||
autofocus
|
||||
@keydown.stop="(e) => {if (e.key == 'Enter') debug(e)}"
|
||||
@input="labelSearch = null;"
|
||||
:search-input.sync="labelSearch"
|
||||
>
|
||||
|
||||
<template v-slot:selection="data">
|
||||
<v-chip
|
||||
v-bind="data.attrs"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
@click="data.select"
|
||||
:color="labels[data.item].view.colour"
|
||||
:title="labels[data.item].view.description"
|
||||
>{{data.item}}</v-chip>
|
||||
</template>
|
||||
|
||||
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
</v-col>
|
||||
<v-col class="flex-grow-0">
|
||||
<v-chip-group>
|
||||
<v-chip v-for="label in editedItem.labels" :key="label"
|
||||
small
|
||||
:close="labels[label].model.user"
|
||||
:color="labels[label].view.colour"
|
||||
:title="labels[label].view.description"
|
||||
@click:close="removeLabel(label, editedItem)"
|
||||
>{{label}}</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
></v-text-field>
|
||||
</template>
|
||||
|
||||
<v-icon v-if="editedRow.items.length == 0 || editedRow.items[editedRow.items.length-1].remarks"
|
||||
color="primary"
|
||||
title="Add comment"
|
||||
class="mb-2"
|
||||
@click="addEvent"
|
||||
>mdi-plus-circle</v-icon>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<v-edit-dialog v-else
|
||||
@save="rowEditorSave"
|
||||
@cancel="rowEditorCancel"
|
||||
@open="rowEditorOpen"
|
||||
@close="rowEditorClose"
|
||||
>
|
||||
<template v-slot:input>
|
||||
<v-text-field
|
||||
v-model="props.item.remarks[0]"
|
||||
label="Edit"
|
||||
single-line
|
||||
></v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
</v-edit-dialog>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-html="$options.filters.markdownInline(item.items.map(i => i.remarks).join('<br/>'))"></div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -213,15 +267,15 @@
|
||||
<!-- Actions column (FIXME currently not used) -->
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div style="white-space:nowrap;">
|
||||
<v-icon v-if="$root.user || true"
|
||||
small
|
||||
class="mr-2"
|
||||
title="View on map"
|
||||
@click="viewOnMap(item)"
|
||||
disabled
|
||||
>
|
||||
mdi-map
|
||||
</v-icon>
|
||||
<a :href="viewOnMap(item)" v-if="viewOnMap(item)">
|
||||
<v-icon v-if="$root.user || true"
|
||||
small
|
||||
class="mr-2"
|
||||
title="View on map"
|
||||
>
|
||||
mdi-map
|
||||
</v-icon>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -306,10 +360,11 @@ export default {
|
||||
},
|
||||
selectedLabels: [],
|
||||
labelSearch: null,
|
||||
queuedReload: false
|
||||
queuedReload: false,
|
||||
itemsPerPage: 25
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
computed: {
|
||||
rows () {
|
||||
const rows = {};
|
||||
@@ -350,7 +405,7 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
...mapGetters(['loading', 'online', 'sequence', 'line', 'point', 'lineName', 'serverEvent']),
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'online', 'sequence', 'line', 'point', 'lineName', 'serverEvent']),
|
||||
...mapState({projectSchema: state => state.project.projectSchema})
|
||||
|
||||
},
|
||||
@@ -394,6 +449,14 @@ export default {
|
||||
if (!newVal && oldVal && this.queuedReload) {
|
||||
this.getEvents();
|
||||
}
|
||||
},
|
||||
|
||||
itemsPerPage (newVal, oldVal) {
|
||||
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
|
||||
},
|
||||
|
||||
user (newVal, oldVal) {
|
||||
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
|
||||
}
|
||||
|
||||
},
|
||||
@@ -469,14 +532,19 @@ export default {
|
||||
},
|
||||
|
||||
async saveEvent (event) {
|
||||
const callback = (err, res) => {
|
||||
if (!err && res.ok) {
|
||||
this.showSnack(["New event saved", "success"]);
|
||||
this.queuedReload = true;
|
||||
this.getEvents({cache: "reload"});
|
||||
}
|
||||
}
|
||||
|
||||
const url = `/project/${this.$route.params.project}/event`;
|
||||
await this.api([url, {
|
||||
method: "POST",
|
||||
body: event
|
||||
}]);
|
||||
this.showSnack(["New event saved", "success"]);
|
||||
this.queuedReload = true;
|
||||
this.getEvents({cache: "reload"});
|
||||
}, callback]);
|
||||
},
|
||||
|
||||
rowEditorOpen (row) {
|
||||
@@ -515,6 +583,11 @@ export default {
|
||||
const promises = [];
|
||||
|
||||
for (const editedItem of this.editedRow.items) {
|
||||
|
||||
// Process special text in remarks
|
||||
if (editedItem.remarks) {
|
||||
editedItem.remarks = this.$options.filters.position(editedItem.remarks, editedItem);
|
||||
}
|
||||
|
||||
// Discard non user writable labels
|
||||
editedItem.labels = editedItem.labels.filter(l => this.labels[l].model.user);
|
||||
@@ -673,12 +746,21 @@ export default {
|
||||
item.items.some( i => i.labels.some( l => l.toLowerCase().includes(s) ));
|
||||
}
|
||||
},
|
||||
|
||||
viewOnMap(row) {
|
||||
if (row && row.items && row.items.length) {
|
||||
if (row.items[0].geometry && row.items[0].geometry.type == "Point") {
|
||||
const [ lon, lat ] = row.items[0].geometry.coordinates;
|
||||
return `map#15/${lon.toFixed(6)}/${lat.toFixed(6)}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
...mapActions(["api", "showSnack"])
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
await this.getLabelDefinitions()
|
||||
await this.getLabelDefinitions();
|
||||
this.getEventCount();
|
||||
this.getEvents();
|
||||
this.getPresetRemarks();
|
||||
|
||||
98
lib/www/client/source/src/views/Login.vue
Normal file
98
lib/www/client/source/src/views/Login.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-form :disabled="loading">
|
||||
<v-card class="mx-auto" max-width="600" tile>
|
||||
<v-card-title style="word-break: normal;">Login</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<v-text-field
|
||||
v-model="credentials.user"
|
||||
label="User"
|
||||
required
|
||||
>
|
||||
</v-text-field>
|
||||
<v-text-field
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
label="Password"
|
||||
required
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
|
||||
<v-btn
|
||||
:disabled="!valid"
|
||||
color="success"
|
||||
class="mr-4"
|
||||
@click="submit"
|
||||
>Login
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="warning"
|
||||
class="mr-4"
|
||||
@click="reset"
|
||||
>Reset
|
||||
</v-btn>
|
||||
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'Login',
|
||||
|
||||
components: {
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
credentials: {
|
||||
user: null,
|
||||
password: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
valid () {
|
||||
return this.credentials.user !== null && this.credentials.password !== null;
|
||||
},
|
||||
|
||||
...mapGetters(['loading', 'user'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
async submit () {
|
||||
|
||||
await this.logout();
|
||||
await this.login(this.credentials);
|
||||
|
||||
if (this.user && !this.user.autologin) {
|
||||
this.$router.replace("/");
|
||||
} else {
|
||||
this.showSnack(["Bad login", "warning"]);
|
||||
}
|
||||
},
|
||||
|
||||
reset () {
|
||||
this.credentials = {user: null, password: null};
|
||||
},
|
||||
|
||||
...mapActions(["login", "logout", "showSnack"])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
19
lib/www/client/source/src/views/Logout.vue
Normal file
19
lib/www/client/source/src/views/Logout.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'Logout',
|
||||
|
||||
methods: {
|
||||
...mapActions(["logout"])
|
||||
},
|
||||
|
||||
async created () {
|
||||
await this.logout();
|
||||
this.$router.replace("/");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -31,6 +31,7 @@ import 'leaflet-arrowheads'
|
||||
import { mapActions, mapGetters, mapState } from 'vuex';
|
||||
import ftstamp from '@/lib/FormatTimestamp'
|
||||
import zoomFitIcon from '@/assets/zoom-fit-best.svg'
|
||||
import { markdown, markdownInline } from '@/lib/markdown';
|
||||
|
||||
var map;
|
||||
|
||||
@@ -87,6 +88,30 @@ const layers = {
|
||||
},
|
||||
}),
|
||||
|
||||
"Saillines": L.geoJSON(null, {
|
||||
pointToLayer (point, latlng) {
|
||||
return L.circle(latlng, {
|
||||
radius: 1,
|
||||
color: "#3388ff",
|
||||
stroke: false,
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
},
|
||||
style (feature) {
|
||||
return {
|
||||
opacity: feature.properties.ntba ? 0.2 : 0.5,
|
||||
color: "cyan"
|
||||
}
|
||||
},
|
||||
onEachFeature (feature, layer) {
|
||||
const p = feature.properties;
|
||||
const popup = feature.geometry.type == "Point"
|
||||
? `Preplot<br/>Point <b>${p.line} / ${p.point}</b>`
|
||||
: `Preplot${p.ntba? " (NTBA)":""}<br/>Line <b>${p.line}</b>${p.remarks ? markdown(p.remarks) : ""}`;
|
||||
layer.bindTooltip(popup, {sticky: true});
|
||||
},
|
||||
}),
|
||||
|
||||
"Plan": L.geoJSON(null, {
|
||||
arrowheads: {
|
||||
size: "8px",
|
||||
@@ -94,7 +119,7 @@ const layers = {
|
||||
},
|
||||
style (feature) {
|
||||
return {
|
||||
color: "magenta",
|
||||
color: "brown",
|
||||
opacity: 0.7
|
||||
}
|
||||
},
|
||||
@@ -108,7 +133,7 @@ const layers = {
|
||||
const speed = (p.length / (new Date(p.ts1) - new Date(p.ts0))) * 3.6/1.852 * 1000;
|
||||
|
||||
const remarks = p.remarks
|
||||
? "<hr/>"+p.remarks
|
||||
? "<hr/>"+markdownInline(p.remarks)
|
||||
: "";
|
||||
|
||||
const popup = `Planned sequence <b>${p.sequence}</b><br/>
|
||||
@@ -146,7 +171,7 @@ const layers = {
|
||||
: "";
|
||||
|
||||
const remarks = p.remarks
|
||||
? "<hr/>"+p.remarks
|
||||
? "<hr/>"+markdown(p.remarks)
|
||||
: "";
|
||||
|
||||
const popup = feature.geometry.type == "Point"
|
||||
@@ -181,7 +206,7 @@ const layers = {
|
||||
const p = feature.properties;
|
||||
|
||||
const remarks = p.remarks
|
||||
? "<hr/>"+p.remarks
|
||||
? "<hr/>"+markdown(p.remarks)
|
||||
: "";
|
||||
|
||||
const popup = feature.geometry.type == "Point"
|
||||
@@ -289,7 +314,7 @@ function makeRealTimePopup(feature) {
|
||||
Position as of ${p.tstamp}<br/><hr/>
|
||||
${online}
|
||||
<table>
|
||||
<tr><td><b>Speed:</b></td><td>${p.speed ? p.speed*3.6/1.852 : "???"} kt</td></tr>
|
||||
<tr><td><b>Speed:</b></td><td>${p.speed ? (p.speed*3.6/1.852).toFixed(1) : "???"} kt</td></tr>
|
||||
<tr><td><b>CMG:</b></td><td>${p.cmg || "???"}°</td></tr>
|
||||
<tr><td><b>Water depth:</b></td><td>${p.waterDepth || "???"} m</td></tr>
|
||||
<tr><td><b>WGS84:</b></td><td>${wgs84}</td></tr>
|
||||
@@ -316,6 +341,16 @@ export default {
|
||||
: `/project/${this.$route.params.project}/gis/preplot/point?${query.toString()}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
layer: layers["Saillines"],
|
||||
url: (query = "") => {
|
||||
const q = new URLSearchParams(query);
|
||||
q.set("class", "V");
|
||||
return map.getZoom() < 18
|
||||
? `/project/${this.$route.params.project}/gis/preplot/line?${q.toString()}`
|
||||
: `/project/${this.$route.params.project}/gis/preplot/point?${q.toString()}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
layer: layers.Plan,
|
||||
url: (query = "") => {
|
||||
@@ -338,12 +373,13 @@ export default {
|
||||
: `/project/${this.$route.params.project}/gis/final/point?${query.toString()}`;
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
hashMarker: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['loading', 'serverEvent', 'lineName', 'serverEvent']),
|
||||
...mapGetters(['user', 'loading', 'serverEvent', 'lineName', 'serverEvent']),
|
||||
...mapState({projectSchema: state => state.project.projectSchema})
|
||||
},
|
||||
|
||||
@@ -358,6 +394,12 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
user (newVal, oldVal) {
|
||||
if (newVal && (!oldVal || newVal.name != oldVal.name)) {
|
||||
this.initView();
|
||||
}
|
||||
},
|
||||
|
||||
serverEvent (event) {
|
||||
if (event.channel == "realtime" && event.payload && event.payload.new) {
|
||||
@@ -371,7 +413,13 @@ export default {
|
||||
rtLayer.update(geojson);
|
||||
}
|
||||
} else if (event.channel == "event" && event.payload.schema == this.projectSchema) {
|
||||
console.log("EVENT", event);
|
||||
//console.log("EVENT", event);
|
||||
}
|
||||
},
|
||||
|
||||
$route (to, from) {
|
||||
if (to.name == "map") {
|
||||
this.setHashMarker();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -487,6 +535,7 @@ export default {
|
||||
const zoom = map.getZoom();
|
||||
const o = [];
|
||||
const l = [];
|
||||
let value;
|
||||
if (includeLayers) {
|
||||
for (const overlay of Object.keys(tileMaps)) {
|
||||
if (map.hasLayer(tileMaps[overlay])) {
|
||||
@@ -498,14 +547,24 @@ export default {
|
||||
l.push(layer);
|
||||
}
|
||||
}
|
||||
document.location.hash = `${zoom}/${lat.toFixed(4)}/${lng.toFixed(4)}:${o.join(";")}:${l.join(";")}`;
|
||||
value = `${zoom}/${lat}/${lng}:${o.join(";")}:${l.join(";")}`;
|
||||
} else {
|
||||
document.location.hash = `${zoom}/${lat.toFixed(4)}/${lng.toFixed(4)}`;
|
||||
value = `${zoom}/${lat}/${lng}`;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view`, value);
|
||||
}
|
||||
},
|
||||
|
||||
decodeURL () {
|
||||
const parts = document.location.hash.substring(1).split(":").map(p => decodeURIComponent(p));
|
||||
const value = localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/view`);
|
||||
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parts = value.split(":");
|
||||
const activeOverlays = parts.length > 1 && parts[1].split(";");
|
||||
const activeLayers = parts.length > 2 && parts[2].split(";");
|
||||
let position = parts && parts[0].split("/").map(i => Number(i));
|
||||
@@ -515,6 +574,102 @@ export default {
|
||||
|
||||
return {position, activeOverlays, activeLayers};
|
||||
},
|
||||
|
||||
initView () {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.off('overlayadd', this.updateURL);
|
||||
map.off('overlayremove', this.updateURL);
|
||||
map.off('layeradd', this.updateURL);
|
||||
map.off('layerremove', this.updateURL);
|
||||
|
||||
const init = this.decodeURL();
|
||||
|
||||
if (init.activeOverlays) {
|
||||
Object.keys(tileMaps).forEach(k => {
|
||||
const l = tileMaps[k];
|
||||
if (init.activeOverlays.includes(k)) {
|
||||
if (!map.hasLayer(l)) {
|
||||
l.addTo(map);
|
||||
}
|
||||
} else {
|
||||
map.removeLayer(l);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tileMaps["No background"].addTo(map);
|
||||
}
|
||||
|
||||
if (init.activeLayers) {
|
||||
Object.keys(layers).forEach(k => {
|
||||
const l = layers[k];
|
||||
if (init.activeLayers.includes(k)) {
|
||||
if (!map.hasLayer(l)) {
|
||||
l.addTo(map);
|
||||
}
|
||||
} else {
|
||||
map.removeLayer(l);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
layers.OpenSeaMap.addTo(map);
|
||||
layers.Preplots.addTo(map);
|
||||
}
|
||||
|
||||
if (init.position) {
|
||||
map.setView(init.position.slice(1), init.position[0]);
|
||||
}
|
||||
|
||||
map.on('overlayadd', this.updateURL);
|
||||
map.on('overlayremove', this.updateURL);
|
||||
map.on('layeradd', this.updateURL);
|
||||
map.on('layerremove', this.updateURL);
|
||||
|
||||
},
|
||||
|
||||
setHashMarker () {
|
||||
|
||||
const crosshairsMarkerIcon = L.divIcon({
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
className: 'svgmarker',
|
||||
html: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path style="fill:inherit;fill-opacity:1;stroke:none"
|
||||
d="M 7 3 L 7 4.03125 A 4.5 4.5 0 0 0 3.0332031 8 L 2 8 L 2 9 L 3.03125 9 A 4.5 4.5 0 0 0 7 12.966797 L 7 14 L 8 14 L 8 12.96875 A 4.5 4.5 0 0 0 11.966797 9 L 13 9 L 13 8 L 11.96875 8 A 4.5 4.5 0 0 0 8 4.0332031 L 8 3 L 7 3 z M 7 5.0390625 L 7 8 L 4.0410156 8 A 3.5 3.5 0 0 1 7 5.0390625 z M 8 5.0410156 A 3.5 3.5 0 0 1 10.960938 8 L 8 8 L 8 5.0410156 z M 4.0390625 9 L 7 9 L 7 11.958984 A 3.5 3.5 0 0 1 4.0390625 9 z M 8 9 L 10.958984 9 A 3.5 3.5 0 0 1 8 11.960938 L 8 9 z "
|
||||
/>
|
||||
</svg>
|
||||
`
|
||||
});
|
||||
|
||||
const updateMarker = (latlng) => {
|
||||
if (this.hashMarker) {
|
||||
if (latlng) {
|
||||
this.hashMarker.setLatLng(latlng);
|
||||
} else {
|
||||
map.removeLayer(this.hashMarker);
|
||||
this.hashMarker = null;
|
||||
}
|
||||
} else if (latlng) {
|
||||
this.hashMarker = L.marker(latlng, {icon: crosshairsMarkerIcon, interactive: false});
|
||||
this.hashMarker.addTo(map).getElement().style.fill = "fuchsia";
|
||||
}
|
||||
}
|
||||
|
||||
const parts = document.location.hash.substring(1).split(":")[0].split("/").map(p => decodeURIComponent(p));
|
||||
if (parts.length == 3) {
|
||||
setTimeout(() => map.setView(parts.slice(1).reverse(), parts[0]), 500);
|
||||
updateMarker(parts.slice(1).reverse());
|
||||
} else if (parts.length == 2) {
|
||||
parts.reverse();
|
||||
setTimeout(() => map.panTo(parts), 500);
|
||||
updateMarker(parts);
|
||||
} else {
|
||||
updateMarker();
|
||||
}
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
|
||||
@@ -541,8 +696,8 @@ export default {
|
||||
onEachFeature (feature, layer) {
|
||||
const p = feature.properties;
|
||||
const popup = (p.sequence
|
||||
? `Event @ ${p.tstamp}<br/>Sequence ${p.sequence}<br/>Point <b>${p.line} / ${p.point}</b><br/><hr/>${p.remarks}`
|
||||
: `Event @ ${p.tstamp}<br/><hr/>${p.remarks}`)
|
||||
? `Event @ ${p.tstamp}<br/>Sequence ${p.sequence}<br/>Point <b>${p.line} / ${p.point}</b><br/><hr/>${markdownInline(p.remarks)}`
|
||||
: `Event @ ${p.tstamp}<br/><hr/>${markdownInline(p.remarks)}`)
|
||||
+ (p.labels.length? `<br/>[<i>${p.labels.join(", ")}</i>]` : "");
|
||||
layer.bindTooltip(popup, {sticky: true});
|
||||
}
|
||||
@@ -553,25 +708,25 @@ export default {
|
||||
layers["Events (Other)"] = L.realtime(this.getEvents(i => i.properties.type != "qc"), eventsOptions());
|
||||
|
||||
layers["Events (Other)"].on('update', function (e) {
|
||||
console.log("Events (Other) update event", e);
|
||||
//console.log("Events (Other) update event", e);
|
||||
});
|
||||
|
||||
layers["Events (QC)"].on('add', function (e) {
|
||||
console.log("Events (QC) add event", e);
|
||||
//console.log("Events (QC) add event", e);
|
||||
e.target._src(data => e.target.update(data), err => console.error)
|
||||
});
|
||||
|
||||
layers["Events (QC)"].on('remove', function (e) {
|
||||
console.log("Events (QC) remove event", e);
|
||||
//console.log("Events (QC) remove event", e);
|
||||
});
|
||||
|
||||
layers["Events (Other)"].on('add', function (e) {
|
||||
console.log("Events (Other) add event", e);
|
||||
//console.log("Events (Other) add event", e);
|
||||
e.target._src(data => e.target.update(data), err => console.error)
|
||||
});
|
||||
|
||||
layers["Events (Other)"].on('remove', function (e) {
|
||||
console.log("Events (Other) remove event", e);
|
||||
//console.log("Events (Other) remove event", e);
|
||||
});
|
||||
|
||||
|
||||
@@ -627,7 +782,11 @@ export default {
|
||||
if (init.position) {
|
||||
this.refreshLayers();
|
||||
} else {
|
||||
this.fitProjectBounds();
|
||||
setTimeout(() => {
|
||||
if(!this.decodeURL().position) {
|
||||
this.fitProjectBounds();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// /usr/share/icons/breeze/actions/16/zoom-fit-best.svg
|
||||
@@ -669,6 +828,9 @@ export default {
|
||||
});
|
||||
|
||||
(new LoadingControl({position: "bottomright"})).addTo(map);
|
||||
|
||||
// Decode a position if one given in the hash
|
||||
this.setHashMarker();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,39 @@
|
||||
<v-card-title>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>Plan</v-toolbar-title>
|
||||
|
||||
<v-menu v-if="items">
|
||||
<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}/plan/?mime=text%2Fcsv&download`"
|
||||
title="Download as a comma-separated values file."
|
||||
>CSV</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/plan/?mime=application%2Fgeo%2Bjson&download`"
|
||||
title="Download as a QGIS-compatible GeoJSON file"
|
||||
>GeoJSON</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/plan/?mime=application%2Fjson&download`"
|
||||
title="Download as a generic JSON file"
|
||||
>JSON</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/plan/?mime=text%2Fhtml&download`"
|
||||
title="Download as an HTML formatted file"
|
||||
>HTML</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/plan/?mime=application%2Fpdf&download`"
|
||||
title="Download as a Portable Document File"
|
||||
>PDF</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="filter"
|
||||
@@ -16,7 +49,7 @@
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<v-menu
|
||||
<v-menu v-if="writeaccess"
|
||||
v-model="contextMenuShow"
|
||||
:position-x="contextMenuX"
|
||||
:position-y="contextMenuY"
|
||||
@@ -30,10 +63,52 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-card class="mb-5" flat>
|
||||
<v-card-title class="text-overline">
|
||||
Comments
|
||||
<template v-if="writeaccess">
|
||||
<v-btn v-if="!editRemarks"
|
||||
class="ml-3"
|
||||
small
|
||||
icon
|
||||
title="Edit comments"
|
||||
@click="editRemarks=true"
|
||||
>
|
||||
<v-icon small>mdi-square-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-else
|
||||
class="ml-3"
|
||||
small
|
||||
icon
|
||||
title="Save comments"
|
||||
@click="saveRemarks"
|
||||
>
|
||||
<v-icon>mdi-content-save-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text v-if="editRemarks">
|
||||
<v-textarea
|
||||
v-model="remarks"
|
||||
class="markdown"
|
||||
placeholder="Plan comments"
|
||||
dense
|
||||
auto-grow
|
||||
rows="1"
|
||||
></v-textarea>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text v-else v-html="$options.filters.markdown(remarks || '*(nil)*')"></v-card-text>
|
||||
|
||||
</v-card>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:items-per-page.sync="itemsPerPage"
|
||||
:search="filter"
|
||||
:loading="loading"
|
||||
:fixed-header="true"
|
||||
@@ -43,8 +118,12 @@
|
||||
@contextmenu:row="contextMenu"
|
||||
>
|
||||
|
||||
<template v-slot:item.srss="{item}">
|
||||
<v-icon small :title="srssInfo(item)">{{srssIcon(item)}}</v-icon>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.sequence="{item, value}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'sequence')"
|
||||
@save="edit = null"
|
||||
@@ -58,12 +137,18 @@
|
||||
single-line
|
||||
>
|
||||
</v-text-field>
|
||||
<v-checkbox
|
||||
v-model="shiftAll"
|
||||
class="mt-0"
|
||||
label="Shift all planned sequences"
|
||||
></v-checkbox>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else>{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.name="{item, value}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'name')"
|
||||
@save="edit = null"
|
||||
@@ -78,10 +163,11 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else>{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.fsp="{item, value}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'fsp')"
|
||||
@save="edit = null"
|
||||
@@ -97,10 +183,11 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else>{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.lsp="{item, value}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'lsp')"
|
||||
@save="edit = null"
|
||||
@@ -116,12 +203,13 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else>{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.ts0="{item, value}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'ts0', item.ts1.toISOString())"
|
||||
@open="editItem(item, 'ts0', item.ts0.toISOString())"
|
||||
@save="edit = null"
|
||||
@cancel="edit.value = item.ts0; edit = null"
|
||||
>
|
||||
@@ -135,10 +223,11 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else>{{ value.toISOString ? value.toISOString().slice(0, 16) : "" }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.ts1="{item, value}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'ts1', item.ts1.toISOString())"
|
||||
@save="edit = null"
|
||||
@@ -154,6 +243,7 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else>{{ value.toISOString ? value.toISOString().slice(0, 16) : "" }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.length="props">
|
||||
@@ -161,11 +251,11 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.azimuth="props">
|
||||
<span style="white-space:nowrap;">{{ props.value.toFixed(1) }} °</span>
|
||||
<span style="white-space:nowrap;">{{ props.value.toFixed(2) }} °</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.remarks="{item}">
|
||||
<v-text-field v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'"
|
||||
<v-text-field v-if="writeaccess && edit && edit.sequence == item.sequence && edit.key == 'remarks'"
|
||||
type="text"
|
||||
v-model="edit.value"
|
||||
prepend-icon="mdi-restore"
|
||||
@@ -176,8 +266,8 @@
|
||||
>
|
||||
</v-text-field>
|
||||
<div v-else>
|
||||
{{item.remarks}}
|
||||
<v-btn v-if="edit === null"
|
||||
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
|
||||
<v-btn v-if="edit === null && writeaccess"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
@@ -191,7 +281,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.speed="{item}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'speed', knots(item).toFixed(1))"
|
||||
@save="edit = null"
|
||||
@@ -209,10 +299,11 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else style="white-space:nowrap;">{{ knots(item).toFixed(1) }} kt</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.lag="{item}">
|
||||
<v-edit-dialog
|
||||
<v-edit-dialog v-if="writeaccess"
|
||||
large
|
||||
@open="editItem(item, 'lagAfter', Math.round(lagAfter(item)/(60*1000)))"
|
||||
@save="edit = null"
|
||||
@@ -229,6 +320,7 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-edit-dialog>
|
||||
<span v-else>{{ Math.round(lagAfter(item) / (60*1000)) }} min</span>
|
||||
</template>
|
||||
|
||||
</v-data-table>
|
||||
@@ -242,10 +334,11 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import suncalc from 'suncalc';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: "LineList",
|
||||
name: "Plan",
|
||||
|
||||
components: {
|
||||
},
|
||||
@@ -257,6 +350,10 @@ export default {
|
||||
value: "sequence",
|
||||
text: "Sequence"
|
||||
},
|
||||
{
|
||||
value: "srss",
|
||||
text: "SR/SS"
|
||||
},
|
||||
{
|
||||
value: "name",
|
||||
text: "Name"
|
||||
@@ -313,13 +410,17 @@ export default {
|
||||
}
|
||||
],
|
||||
items: [],
|
||||
remarks: null,
|
||||
editRemarks: false,
|
||||
filter: null,
|
||||
num_lines: null,
|
||||
activeItem: null,
|
||||
edit: null, // {sequence, key, value}
|
||||
queuedReload: false,
|
||||
itemsPerPage: 25,
|
||||
|
||||
plannerConfig: null,
|
||||
shiftAll: false, // Shift all sequences checkbox
|
||||
|
||||
// Context menu stuff
|
||||
contextMenuShow: false,
|
||||
@@ -330,7 +431,7 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['loading', 'serverEvent'])
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -344,31 +445,37 @@ export default {
|
||||
if (oldVal.value === null) oldVal.value = "";
|
||||
|
||||
if (item) {
|
||||
if (oldVal.key == "lagAfter") {
|
||||
// We need to shift the times for every subsequent sequence
|
||||
const delta = oldVal.value*60*1000 - this.lagAfter(item);
|
||||
await this.shiftTimesAfter(item, delta);
|
||||
} else if (oldVal.key == "speed") {
|
||||
const v = oldVal.value*(1.852/3.6)/1000; // m/s
|
||||
const ts1 = new Date(item.ts0.valueOf() + item.length / v);
|
||||
const delta = ts1 - item.ts1;
|
||||
await this.shiftTimesAfter(item, delta);
|
||||
await this.saveItem({sequence: item.sequence, key: 'ts1', value: ts1});
|
||||
} else if (oldVal.key == "sequence") {
|
||||
await this.shiftSequences(oldVal.value-item.sequence);
|
||||
} else if (item[oldVal.key] != oldVal.value) {
|
||||
if (item[oldVal.key] != oldVal.value) {
|
||||
if (oldVal.key == "lagAfter") {
|
||||
// Convert from minutes to seconds
|
||||
oldVal.value *= 60;
|
||||
} else if (oldVal.key == "speed") {
|
||||
// Convert knots to metres per second
|
||||
oldVal.value = oldVal.value*(1.852/3.6);
|
||||
}
|
||||
|
||||
if (await this.saveItem(oldVal)) {
|
||||
item[oldVal.key] = oldVal.value;
|
||||
} else {
|
||||
this.edit = oldVal;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
async serverEvent (event) {
|
||||
if (event.channel == "planned_lines" && event.payload.pid == this.$route.params.project) {
|
||||
|
||||
// Ignore non-ops
|
||||
/*
|
||||
if (event.payload.old === null && event.payload.new === null) {
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
if (!this.loading && !this.queuedReload) {
|
||||
// Do not force a non-cached response if refreshing as a result
|
||||
// of an event notification. We will assume that the server has
|
||||
@@ -378,6 +485,10 @@ export default {
|
||||
} else {
|
||||
this.queuedReload = true;
|
||||
}
|
||||
} else if (event.channel == "info" && event.payload.pid == this.$route.params.project) {
|
||||
if (event.payload?.new?.key == "plan" && ("remarks" in (event.payload?.new?.value || {}))) {
|
||||
this.remarks = event.payload?.new.value.remarks;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -391,11 +502,133 @@ export default {
|
||||
if (!newVal && oldVal && this.queuedReload) {
|
||||
this.getPlannedLines();
|
||||
}
|
||||
},
|
||||
|
||||
itemsPerPage (newVal, oldVal) {
|
||||
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
|
||||
},
|
||||
|
||||
user (newVal, oldVal) {
|
||||
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
suntimes (line) {
|
||||
const oneday = 86400000;
|
||||
|
||||
function isDay (srss, ts, lat, lng) {
|
||||
if (isNaN(srss.sunriseEnd) || isNaN(srss.sunsetStart)) {
|
||||
// Between March and September
|
||||
ts = new Date(ts);
|
||||
if (ts.getMonth() >= 2 && ts.getMonth() <= 8) {
|
||||
// Polar day in the Northern hemisphere, night in the South
|
||||
return lat > 0;
|
||||
} else {
|
||||
return lat < 0;
|
||||
}
|
||||
} else {
|
||||
if (srss.sunriseEnd < ts) {
|
||||
if (ts < srss.sunsetStart) {
|
||||
return true;
|
||||
} else {
|
||||
return suncalc.getTimes(new Date(ts.valueOf() + oneday), lat, lng).sunriseEnd < ts;
|
||||
}
|
||||
} else {
|
||||
return ts < suncalc.getTimes(new Date(ts.valueOf() - oneday), lat, lng).sunsetStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let {ts0, ts1} = line;
|
||||
const [ lng0, lat0 ] = line.geometry.coordinates[0];
|
||||
const [ lng1, lat1 ] = line.geometry.coordinates[1];
|
||||
|
||||
if (ts1-ts0 > oneday) {
|
||||
console.warn("Cannot provide reliable sunrise / sunset times for lines over 24 hr in this version");
|
||||
//return null;
|
||||
}
|
||||
|
||||
const srss0 = suncalc.getTimes(ts0, lat0, lng0);
|
||||
const srss1 = suncalc.getTimes(ts1, lat1, lng1);
|
||||
|
||||
srss0.prevDay = suncalc.getTimes(new Date(ts0.valueOf()-oneday), lat0, lng0);
|
||||
srss1.nextDay = suncalc.getTimes(new Date(ts1.valueOf()+oneday), lat1, lng1);
|
||||
|
||||
srss0.isDay = isDay(srss0, ts0, lat0, lng0);
|
||||
srss1.isDay = isDay(srss1, ts1, lat1, lng1);
|
||||
|
||||
return {
|
||||
ts0: srss0,
|
||||
ts1: srss1
|
||||
};
|
||||
},
|
||||
|
||||
srssIcon (line) {
|
||||
const srss = this.suntimes(line);
|
||||
const moon = suncalc.getMoonIllumination(line.ts0);
|
||||
return srss.ts0.isDay && srss.ts1.isDay
|
||||
? 'mdi-weather-sunny'
|
||||
: !srss.ts0.isDay && !srss.ts1.isDay
|
||||
? moon.phase < 0.05
|
||||
? 'mdi-moon-new'
|
||||
: moon.phase < 0.25
|
||||
? 'mdi-moon-waxing-crescent'
|
||||
: moon.phase < 0.45
|
||||
? 'mdi-moon-waxing-gibbous'
|
||||
: moon.phase < 0.55
|
||||
? 'mdi-moon-full'
|
||||
: moon.phase < 0.75
|
||||
? 'mdi-moon-waning-gibbous'
|
||||
: 'mdi-moon-waning-crescent'
|
||||
: 'mdi-theme-light-dark';
|
||||
},
|
||||
|
||||
srssMoonPhase (line) {
|
||||
const ts = new Date((Number(line.ts0)+Number(line.ts1))/2);
|
||||
const moon = suncalc.getMoonIllumination(ts);
|
||||
return moon.phase < 0.05
|
||||
? 'New moon'
|
||||
: moon.phase < 0.25
|
||||
? 'Waxing crescent moon'
|
||||
: moon.phase < 0.45
|
||||
? 'Waxing gibbous moon'
|
||||
: moon.phase < 0.55
|
||||
? 'Full moon'
|
||||
: moon.phase < 0.75
|
||||
? 'Waning gibbous moon'
|
||||
: 'Waning crescent moon';
|
||||
},
|
||||
|
||||
srssInfo (line) {
|
||||
const srss = this.suntimes(line);
|
||||
const text = [];
|
||||
|
||||
try {
|
||||
text.push(`Sunset at\t${srss.ts0.prevDay.sunset.toISOString().substr(0, 16)}Z (FSP)`);
|
||||
text.push(`Sunrise at\t${srss.ts0.sunrise.toISOString().substr(0, 16)}Z (FSP)`);
|
||||
text.push(`Sunset at\t${srss.ts0.sunset.toISOString().substr(0, 16)}Z (FSP)`);
|
||||
if (line.ts0.getUTCDate() != line.ts1.getUTCDate()) {
|
||||
text.push(`Sunrise at\t${srss.ts1.sunrise.toISOString().substr(0, 16)}Z (LSP)`);
|
||||
text.push(`Sunset at\t${srss.ts1.sunset.toISOString().substr(0, 16)}Z (LSP)`);
|
||||
}
|
||||
text.push(`Sunrise at\t${srss.ts1.nextDay.sunrise.toISOString().substr(0, 16)}Z (LSP)`);
|
||||
} catch (err) {
|
||||
if (err instanceof RangeError) {
|
||||
text.push(srss.ts0.isDay ? "Polar day" : "Polar night");
|
||||
} else {
|
||||
console.log("ERROR", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!srss.ts0.isDay || !srss.ts1.isDay) {
|
||||
text.push(this.srssMoonPhase(line));
|
||||
}
|
||||
|
||||
return text.join("\n");
|
||||
},
|
||||
|
||||
lagAfter (item) {
|
||||
const pos = this.items.indexOf(item)+1;
|
||||
@@ -431,60 +664,7 @@ export default {
|
||||
await this.api([url, init]);
|
||||
await this.getPlannedLines();
|
||||
},
|
||||
|
||||
async shiftSequences(delta) {
|
||||
const lines = delta < 0
|
||||
? this.items
|
||||
: [...this.items].reverse(); // We go backwards so as to avoid conflicts.
|
||||
|
||||
for (const line of lines) {
|
||||
const sequence = line.sequence+delta;
|
||||
const url = `/project/${this.$route.params.project}/plan/${line.sequence}`;
|
||||
const init = {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: {sequence, name: null} // Setting name to null causes it to be regenerated
|
||||
}
|
||||
await this.api([url, init]);
|
||||
}
|
||||
},
|
||||
|
||||
async shiftTimesAfter(item, delta) {
|
||||
const pos = this.items.indexOf(item)+1;
|
||||
if (pos != 0) {
|
||||
const modifiedLines = this.items.slice(pos);
|
||||
if (modifiedLines.length) {
|
||||
modifiedLines.reverse();
|
||||
for (const line of modifiedLines) {
|
||||
const ts0 = new Date(line.ts0.valueOf() + delta);
|
||||
const ts1 = new Date(line.ts1.valueOf() + delta);
|
||||
const url = `/project/${this.$route.params.project}/plan/${line.sequence}`;
|
||||
const init = {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: {ts1, ts0}
|
||||
}
|
||||
await this.api([url, init]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("Item", item, "not found");
|
||||
}
|
||||
},
|
||||
|
||||
editLagAfter (item) {
|
||||
const pos = this.items.indexOf(item)+1;
|
||||
if (pos != 0) {
|
||||
if (pos < this.items.length) {
|
||||
// Not last item
|
||||
this.editedItems = this.items.slice(pos);
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
console.warn("Item", item, "not found");
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
editItem (item, key, value) {
|
||||
this.edit = {
|
||||
sequence: item.sequence,
|
||||
@@ -512,6 +692,27 @@ export default {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveRemarks () {
|
||||
const url = `/project/${this.$route.params.project}/info/plan/remarks`;
|
||||
let res;
|
||||
if (this.remarks) {
|
||||
const init = {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: this.remarks
|
||||
};
|
||||
await this.api([url, init, (e, r) => res = r]);
|
||||
} else {
|
||||
const init = {
|
||||
method: "DELETE"
|
||||
};
|
||||
await this.api([url, init, (e, r) => res = r]);
|
||||
}
|
||||
if (res && res.ok) {
|
||||
this.editRemarks = false;
|
||||
}
|
||||
},
|
||||
|
||||
async getPlannedLines () {
|
||||
|
||||
@@ -535,6 +736,11 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async getPlannerRemarks () {
|
||||
const url = `/project/${this.$route.params.project}/info/plan/remarks`;
|
||||
this.remarks = await this.api([url]) || "";
|
||||
},
|
||||
|
||||
async getSequences () {
|
||||
const url = `/project/${this.$route.params.project}/sequence`;
|
||||
this.sequences = await this.api([url]) || [];
|
||||
@@ -546,12 +752,13 @@ export default {
|
||||
: item;
|
||||
},
|
||||
|
||||
...mapActions(["api"])
|
||||
...mapActions(["api", "showSnack"])
|
||||
},
|
||||
|
||||
async mounted () {
|
||||
await this.getPlannerConfig();
|
||||
this.getPlannedLines();
|
||||
this.getPlannerRemarks();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -83,13 +83,13 @@
|
||||
small
|
||||
:color="labels[label] && labels[label].view.colour"
|
||||
:title="labels[label] && labels[label].view.description"
|
||||
:close="label == 'QCAccepted'"
|
||||
:close="writeaccess && label == 'QCAccepted'"
|
||||
@click:close="unaccept(item)">
|
||||
{{label}}
|
||||
</v-chip>
|
||||
|
||||
<template v-if="!item.labels || !item.labels.includes('QCAccepted')">
|
||||
<v-hover v-slot:default="{hover}">
|
||||
<v-hover v-slot:default="{hover}" v-if="writeaccess">
|
||||
<span v-if="item.children && item.children.length">
|
||||
<v-btn
|
||||
:class="{'text--disabled': !hover}"
|
||||
@@ -98,7 +98,7 @@
|
||||
color="primary"
|
||||
title="Accept all"
|
||||
@click.stop="accept(item)">
|
||||
<v-icon small>mdi-check-all</v-icon>
|
||||
<v-icon small :color="accepted(item) ? 'green' : ''">mdi-check-all</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:class="{'text--disabled': !hover}"
|
||||
@@ -117,7 +117,7 @@
|
||||
color="primary"
|
||||
title="Accept this value"
|
||||
@click="accept(item)">
|
||||
<v-icon small>mdi-check</v-icon>
|
||||
<v-icon small :color="(item.children && item.children.length == 0)? 'green':''">mdi-check</v-icon>
|
||||
</v-btn>
|
||||
</v-hover>
|
||||
</template>
|
||||
@@ -226,7 +226,7 @@ export default {
|
||||
return values;
|
||||
},
|
||||
|
||||
...mapGetters(['loading'])
|
||||
...mapGetters(['writeaccess', 'loading'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -260,6 +260,17 @@ export default {
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
|
||||
accepted (item) {
|
||||
if (item.children) {
|
||||
return item.children.every(child => this.accepted(child));
|
||||
}
|
||||
|
||||
if (item.labels) {
|
||||
return item.labels.includes("QCAccepted");
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
accept (item) {
|
||||
if (item.children) {
|
||||
@@ -478,7 +489,7 @@ export default {
|
||||
if (res) {
|
||||
this.items = res.results.map(i => this.transform(i)) || [];
|
||||
this.updatedOn = res.updatedOn;
|
||||
this.getQCLabels(); // No await?
|
||||
await this.getQCLabels();
|
||||
} else {
|
||||
this.items = [];
|
||||
this.updatedOn = null;
|
||||
|
||||
@@ -21,30 +21,79 @@
|
||||
|
||||
<v-menu
|
||||
v-model="contextMenuShow"
|
||||
:close-on-content-click="false"
|
||||
:position-x="contextMenuX"
|
||||
:position-y="contextMenuY"
|
||||
absolute
|
||||
offset-y
|
||||
>
|
||||
<v-list dense v-if="contextMenuItem">
|
||||
<v-list-item @click="addToPlan(false)">
|
||||
<v-list-item @click="addToPlan(false); contextMenuShow=false" v-if="writeaccess">
|
||||
<v-list-item-title>Reshoot</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="addToPlan(true)">
|
||||
<v-list-item @click="addToPlan(true); contextMenuShow=false" v-if="writeaccess">
|
||||
<v-list-item-title>Reshoot with overlap</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/projects/${$route.params.project}/graphs/sequence/${contextMenuItem.sequence}`"
|
||||
@click="contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-title>View graphics</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-group>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-title>Download report</v-list-item-title>
|
||||
</template>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fvnd.seis%2Bjson&download`"
|
||||
title="Download as a Multiseis-compatible Seis+JSON file."
|
||||
@click="contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-title>Seis+JSON</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fgeo%2Bjson&download`"
|
||||
title="Download as a QGIS-compatible GeoJSON file"
|
||||
@click="contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-title>GeoJSON</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fjson&download`"
|
||||
title="Download as a generic JSON file"
|
||||
@click="contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-title>JSON</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=text%2Fhtml&download`"
|
||||
title="Download as an HTML formatted file"
|
||||
@click="contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-title>HTML</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:href="`/api/project/${$route.params.project}/event/-/${contextMenuItem.sequence}?mime=application%2Fpdf&download`"
|
||||
title="Download as a Portable Document File"
|
||||
@click="contextMenuShow=false"
|
||||
>
|
||||
<v-list-item-title>PDF</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:items-per-page.sync="itemsPerPage"
|
||||
item-key="sequence"
|
||||
:server-items-length="num_rows"
|
||||
:search="filter"
|
||||
:custom-filter="customFilter"
|
||||
:loading="loading"
|
||||
:fixed-header="true"
|
||||
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ]}'
|
||||
show-expand
|
||||
:item-class="(item) => activeItem == item ? 'blue accent-1 elevation-3' : ''"
|
||||
@click:row="setActiveItem"
|
||||
@@ -59,31 +108,45 @@
|
||||
<v-card outlined class="flex-grow-1">
|
||||
<v-card-title>
|
||||
Acquisition remarks
|
||||
<v-btn v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'"
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Save edits"
|
||||
:disabled="loading"
|
||||
@click="edit = null"
|
||||
>
|
||||
<v-icon small>mdi-content-save-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else-if="edit === null"
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
:disabled="loading"
|
||||
@click="editItem(item, 'remarks')"
|
||||
>
|
||||
<v-icon small>mdi-square-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
<template v-if="writeaccess">
|
||||
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'">
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Cancel edit"
|
||||
:disabled="loading"
|
||||
@click="edit.value = item.remarks; edit = null"
|
||||
>
|
||||
<v-icon small>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-if="edit.value != item.remarks"
|
||||
icon
|
||||
small
|
||||
title="Save edits"
|
||||
:disabled="loading"
|
||||
@click="edit = null"
|
||||
>
|
||||
<v-icon small>mdi-content-save-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-btn v-else-if="edit === null"
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
:disabled="loading"
|
||||
@click="editItem(item, 'remarks')"
|
||||
>
|
||||
<v-icon small>mdi-square-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
</v-card-subtitle>
|
||||
<v-card-text v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'">
|
||||
<v-textarea
|
||||
class="markdown"
|
||||
autofocus
|
||||
placeholder="Enter your text here"
|
||||
:disabled="loading"
|
||||
@@ -91,38 +154,51 @@
|
||||
>
|
||||
</v-textarea>
|
||||
</v-card-text>
|
||||
<v-card-text v-else>
|
||||
{{ item.remarks }}
|
||||
<v-card-text v-else v-html="$options.filters.markdown(item.remarks)">
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card outlined class="flex-grow-1" v-if="item.remarks_final !== null">
|
||||
<v-card-title>
|
||||
Processing remarks
|
||||
<v-btn v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'"
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Save edits"
|
||||
:disabled="loading"
|
||||
@click="edit = null"
|
||||
>
|
||||
<v-icon small>mdi-content-save-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else-if="edit === null"
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
:disabled="loading"
|
||||
@click="editItem(item, 'remarks_final')"
|
||||
>
|
||||
<v-icon small>mdi-square-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
<template v-if="writeaccess">
|
||||
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'">
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Cancel edit"
|
||||
:disabled="loading"
|
||||
@click="edit.value = item.remarks_final; edit = null"
|
||||
>
|
||||
<v-icon small>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-if="edit.value != item.remarks_final"
|
||||
icon
|
||||
small
|
||||
title="Save edits"
|
||||
:disabled="loading"
|
||||
@click="edit = null"
|
||||
>
|
||||
<v-icon small>mdi-content-save-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-btn v-else-if="edit === null"
|
||||
class="ml-3"
|
||||
icon
|
||||
small
|
||||
title="Edit"
|
||||
:disabled="loading"
|
||||
@click="editItem(item, 'remarks_final')"
|
||||
>
|
||||
<v-icon small>mdi-square-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
</v-card-subtitle>
|
||||
<v-card-text v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'">
|
||||
<v-textarea
|
||||
class="markdown"
|
||||
autofocus
|
||||
placeholder="Enter your text here"
|
||||
:disabled="loading"
|
||||
@@ -130,8 +206,7 @@
|
||||
>
|
||||
</v-textarea>
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
{{ item.remarks_final }}
|
||||
<v-card-text v-html="$options.filters.markdown(item.remarks_final)">
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -262,7 +337,7 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:item.azimuth="{value}">
|
||||
<span>{{ value.toFixed? value.toFixed(1) : value }} °</span>
|
||||
<span>{{ value.toFixed? value.toFixed(2) : value }} °</span>
|
||||
</template>
|
||||
|
||||
</v-data-table>
|
||||
@@ -397,6 +472,7 @@ export default {
|
||||
activeItem: null,
|
||||
edit: null, // {sequence, key, value}
|
||||
queuedReload: false,
|
||||
itemsPerPage: 25,
|
||||
|
||||
// Planner related stuff
|
||||
preplots: null,
|
||||
@@ -411,7 +487,7 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['loading', 'serverEvent'])
|
||||
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -460,6 +536,14 @@ export default {
|
||||
if (!newVal && oldVal && this.queuedReload) {
|
||||
this.getSequences();
|
||||
}
|
||||
},
|
||||
|
||||
itemsPerPage (newVal, oldVal) {
|
||||
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
|
||||
},
|
||||
|
||||
user (newVal, oldVal) {
|
||||
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
@@ -34,3 +34,13 @@ The following environment variables affect the behaviour of the application:
|
||||
* `DOUGAL_API_CONFIG`: Location of the API configuration file. Default is `$HOME/etc/www/config.json`.
|
||||
|
||||
The server always listens on 127.0.0.1. Use a proxy like Nginx to expose it to the network.
|
||||
|
||||
## Use
|
||||
|
||||
The API specification may be found under [`./spec`](./spec).
|
||||
|
||||
Generate the documentation with:
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/redoc-cli bundle ./spec/openapi.yaml
|
||||
```
|
||||
|
||||
@@ -62,8 +62,25 @@ const allMeta = (key, value) => {
|
||||
return { all: [ meta(key, value) ] };
|
||||
};
|
||||
|
||||
// These routes do not require authentication
|
||||
app.map({
|
||||
'*': { all: [ meta() ] }, // Create the req.meta object
|
||||
'/login': {
|
||||
post: [ mw.user.login ]
|
||||
},
|
||||
'/logout': {
|
||||
get: [ mw.user.logout ],
|
||||
post: [ mw.user.logout ]
|
||||
},
|
||||
'/': {
|
||||
get: [ mw.openapi.get ]
|
||||
}
|
||||
});
|
||||
|
||||
app.use(mw.auth.authentify);
|
||||
|
||||
// We must be authenticated before we can access these
|
||||
app.map({
|
||||
'/project': {
|
||||
get: [ mw.project.list ], // Get list of projects
|
||||
},
|
||||
@@ -93,38 +110,41 @@ app.map({
|
||||
},
|
||||
'/project/:project/line/:line': {
|
||||
// get: [ mw.line.get ],
|
||||
patch: [ mw.line.patch ],
|
||||
patch: [ mw.auth.access.write, mw.line.patch ],
|
||||
},
|
||||
|
||||
'/project/:project/sequence/': {
|
||||
get: [ mw.sequence.list ],
|
||||
},
|
||||
'/project/:project/sequence/:sequence': {
|
||||
// get: [ mw.sequence.get ],
|
||||
patch: [ mw.sequence.patch ],
|
||||
get: [ mw.sequence.get ],
|
||||
patch: [ mw.auth.access.write, mw.sequence.patch ],
|
||||
},
|
||||
|
||||
'/project/:project/plan/': {
|
||||
get: [ mw.plan.list ],
|
||||
put: [ mw.plan.put ],
|
||||
post: [ mw.plan.post ]
|
||||
put: [ mw.auth.access.write, mw.plan.put ],
|
||||
post: [ mw.auth.access.write, mw.plan.post ]
|
||||
},
|
||||
'/project/:project/plan/:sequence': {
|
||||
// get: [ mw.plan.get ],
|
||||
patch: [ mw.plan.patch ],
|
||||
delete: [ mw.plan.delete ]
|
||||
patch: [ mw.auth.access.write, mw.plan.patch ],
|
||||
delete: [ mw.auth.access.write, mw.plan.delete ]
|
||||
},
|
||||
//
|
||||
'/project/:project/event/': {
|
||||
get: [ mw.event.cache.get, mw.event.list, mw.event.cache.save ],
|
||||
post: [ mw.event.post ],
|
||||
put: [ mw.event.put ],
|
||||
delete: [ mw.event.delete ],
|
||||
post: [ mw.auth.access.write, mw.event.post ],
|
||||
put: [ mw.auth.access.write, mw.event.put ],
|
||||
delete: [ mw.auth.access.write, mw.event.delete ],
|
||||
'-/:sequence/': { // NOTE: We need to avoid conflict with the next endpoint ☹
|
||||
get: [ mw.event.get ],
|
||||
},
|
||||
':type/': {
|
||||
':id/': {
|
||||
// get: [ mw.event.get ],
|
||||
put: [ mw.event.put ],
|
||||
delete: [ mw.event.delete ]
|
||||
put: [ mw.auth.access.write, mw.event.put ],
|
||||
delete: [mw.auth.access.write, mw.event.delete ]
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -134,14 +154,16 @@ app.map({
|
||||
},
|
||||
'/project/:project/configuration/:path(*)?': {
|
||||
get: [ mw.configuration.get ],
|
||||
// post: [ mw.label.post ],
|
||||
// post: [ mw.auth.access.admin, mw.label.post ],
|
||||
},
|
||||
'/project/:project/info/:path(*)': {
|
||||
get: [ mw.info.get ],
|
||||
// post: [ mw.info.post ],
|
||||
post: [ mw.auth.access.write, mw.info.post ],
|
||||
put: [ mw.auth.access.write, mw.info.put ],
|
||||
delete: [ mw.auth.access.write, mw.info.delete ]
|
||||
},
|
||||
'/project/:project/meta/': {
|
||||
put: [ mw.meta.put ],
|
||||
put: [ mw.auth.access.write, mw.meta.put ],
|
||||
},
|
||||
'/project/:project/meta/:path(*)': {
|
||||
// Path examples:
|
||||
@@ -165,6 +187,17 @@ app.map({
|
||||
'gis/:featuretype(line|point)': {
|
||||
get: [ mw.gis.navdata.get ]
|
||||
}
|
||||
},
|
||||
'/info/': {
|
||||
':path(*)': {
|
||||
get: [ mw.info.get ],
|
||||
put: [ mw.auth.access.write, mw.info.put ],
|
||||
post: [ mw.auth.access.write, mw.info.post ],
|
||||
delete: [ mw.auth.access.write, mw.info.delete ]
|
||||
}
|
||||
},
|
||||
'/rss/': {
|
||||
get: [ mw.rss.get ]
|
||||
}
|
||||
//
|
||||
// '/user': {
|
||||
@@ -177,12 +210,6 @@ app.map({
|
||||
// // delete: [ mw.user.delete ]
|
||||
// },
|
||||
//
|
||||
// '/login': {
|
||||
// post: [ mw.user.login ]
|
||||
// },
|
||||
// '/logout': {
|
||||
// post: [ mw.user.logout ]
|
||||
// }
|
||||
});
|
||||
|
||||
// Generic error handler. Stops stack dumps
|
||||
@@ -195,8 +222,10 @@ app.use(function (err, req, res, next) {
|
||||
|
||||
console.log("Error:", err);
|
||||
|
||||
res.set("Content-Type", "application/json");
|
||||
if (err instanceof Error && err.name != "UnauthorizedError") {
|
||||
console.error(err.stack);
|
||||
res.set("Content-Type", "text/plain");
|
||||
res.status(500).send('General internal error');
|
||||
maybeSendAlert(alert);
|
||||
} else if (typeof err === 'string') {
|
||||
|
||||
31
lib/www/server/api/middleware/auth/access.js
Normal file
31
lib/www/server/api/middleware/auth/access.js
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
|
||||
async function read (req, res, next) {
|
||||
if (req.user) {
|
||||
next();
|
||||
} else {
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
}
|
||||
|
||||
async function write (req, res, next) {
|
||||
if (req.user && (req.user.role == "user" || req.user.role == "admin")) {
|
||||
next();
|
||||
} else {
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
}
|
||||
|
||||
async function admin (req, res, next) {
|
||||
if (req.user && req.user.role == "admin") {
|
||||
next();
|
||||
} else {
|
||||
next({status: 403, message: "Access denied"});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
read,
|
||||
write,
|
||||
admin
|
||||
};
|
||||
92
lib/www/server/api/middleware/auth/authentify.js
Normal file
92
lib/www/server/api/middleware/auth/authentify.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const dns = require('dns');
|
||||
const { Netmask } = require('netmask');
|
||||
const cfg = require('../../../lib/config');
|
||||
const jwt = require('../../../lib/jwt');
|
||||
|
||||
async function authorisedIP (req, res) {
|
||||
const validIPs = cfg._("global.users.login.ip") || {};
|
||||
for (const key in validIPs) {
|
||||
const block = new Netmask(key);
|
||||
if (block.contains(req.ip)) {
|
||||
const payload = Object.assign({
|
||||
ip: req.ip,
|
||||
autologin: true
|
||||
}, validIPs[key]);
|
||||
jwt.issue(payload, req, res);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function authorisedHost (req, res) {
|
||||
const validHosts = cfg._("global.users.login.host") || {};
|
||||
for (const key in validHosts) {
|
||||
try {
|
||||
const ip = await dns.promises.resolve(key);
|
||||
if (ip == req.ip) {
|
||||
const payload = Object.assign({
|
||||
ip: req.ip,
|
||||
host: key,
|
||||
autologin: true
|
||||
}, validHosts[key]);
|
||||
jwt.issue(payload, req, res);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code != "ENODATA") {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function auth (req, res, next) {
|
||||
|
||||
if (res.headersSent) {
|
||||
// Nothing to do, this request must have been
|
||||
// handled already by another middleware.
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for a valid JWT (already decoded by a previous
|
||||
// middleware).
|
||||
if (req.user) {
|
||||
if (!req.user.autologin) {
|
||||
// If this is not an automatic login, check if the token is in the
|
||||
// second half of its lifetime. If so, reissue a new one, valid for
|
||||
// another cfg.jwt.options.expiresIn seconds.
|
||||
if (req.user.exp) {
|
||||
const ttl = req.user.exp - Date.now()/1000;
|
||||
if (ttl < cfg.jwt.options.expiresIn/2) {
|
||||
const credentials = cfg._("global.users.login.user").find(i => i.name == req.user.name && i.role == req.user.role);
|
||||
if (credentials) {
|
||||
// Refresh token
|
||||
payload = Object.assign({}, credentials);
|
||||
delete payload.hash;
|
||||
jwt.issue(Object.assign({}, credentials), req, res);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the IP is known to us
|
||||
if (await authorisedIP(req, res)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the hostname is known to us
|
||||
if (await authorisedHost(req, res)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
next({status: 401, message: "Not authorised"});
|
||||
}
|
||||
|
||||
module.exports = auth;
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
exports.jwt = require('./jwt');
|
||||
// exports.access = require('./access');
|
||||
exports.authentify = require('./authentify');
|
||||
exports.access = require('./access');
|
||||
|
||||
38
lib/www/server/api/middleware/event/get/geojson.js
Normal file
38
lib/www/server/api/middleware/event/get/geojson.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { transform, prepare } = require('../../../../lib/sse');
|
||||
|
||||
const geojson = async function (req, res, next) {
|
||||
try {
|
||||
const query = req.query;
|
||||
query.sequence = req.params.sequence;
|
||||
const {events, sequences} = await prepare(req.params.project, query);
|
||||
const response = {
|
||||
type: "FeatureCollection",
|
||||
features: events.filter(event => event.geometry).map(event => {
|
||||
const feature = {
|
||||
type: "Feature",
|
||||
geometry: event.geometry,
|
||||
properties: event
|
||||
};
|
||||
delete feature.properties.geometry;
|
||||
return feature;
|
||||
})
|
||||
};
|
||||
if ("download" in query || "d" in query) {
|
||||
const extension = "geojson";
|
||||
// Get the sequence number(s) (more than one sequence can be selected)
|
||||
const seqNums = query.sequence.split(";");
|
||||
// If we've only been asked for a single sequence, get its line name
|
||||
const lineName = (sequences.find(i => i.sequence == seqNums[0]) || {})?.meta?.lineName;
|
||||
const filename = (seqNums.length == 1 && lineName)
|
||||
? `${lineName}-NavLog.${extension}`
|
||||
: `${req.params.project}-${query.sequence}.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.status(200).send(response);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = geojson;
|
||||
41
lib/www/server/api/middleware/event/get/html.js
Normal file
41
lib/www/server/api/middleware/event/get/html.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { configuration } = require('../../../../lib/db');
|
||||
const { transform, prepare } = require('../../../../lib/sse');
|
||||
const render = require('../../../../lib/render');
|
||||
|
||||
// FIXME Refactor when able
|
||||
const defaultTemplatePath = require('path').resolve(__dirname, "../../../../../../../etc/default/templates/sequence.html.njk");
|
||||
|
||||
const html = async function (req, res, next) {
|
||||
try {
|
||||
const query = req.query;
|
||||
query.sequence = req.params.sequence;
|
||||
const {events, sequences} = await prepare(req.params.project, query);
|
||||
const seis = transform(events, sequences, {projectId: req.params.project, missingAsEvent: true});
|
||||
const template = (await configuration.get(req.params.project, "sse/templates/0/template")) || defaultTemplatePath;
|
||||
// console.log("TEMPLATE", template);
|
||||
|
||||
const response = await render(seis, template);
|
||||
|
||||
if ("download" in query || "d" in query) {
|
||||
const extension = "html";
|
||||
// Get the sequence number(s) (more than one sequence can be selected)
|
||||
const seqNums = query.sequence.split(";");
|
||||
// If we've only been asked for a single sequence, get its line name
|
||||
const lineName = (sequences.find(i => i.sequence == seqNums[0]) || {})?.meta?.lineName;
|
||||
const filename = (seqNums.length == 1 && lineName)
|
||||
? `${lineName}-NavLog.${extension}`
|
||||
: `${req.params.project}-${query.sequence}.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.status(200).send(response);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.message.startsWith("template")) {
|
||||
next({message: err.message});
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = html;
|
||||
29
lib/www/server/api/middleware/event/get/index.js
Normal file
29
lib/www/server/api/middleware/event/get/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const json = require('./json');
|
||||
const geojson = require('./geojson');
|
||||
const seis = require('./seis');
|
||||
const html = require('./html');
|
||||
const pdf = require('./pdf');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
try {
|
||||
const handlers = {
|
||||
"application/json": json,
|
||||
"application/geo+json": geojson,
|
||||
"application/vnd.seis+json": seis,
|
||||
"text/html": html,
|
||||
"application/pdf": pdf
|
||||
};
|
||||
|
||||
const mimetype = (handlers[req.query.mime] && req.query.mime) || req.accepts(Object.keys(handlers));
|
||||
|
||||
if (mimetype) {
|
||||
res.set("Content-Type", mimetype);
|
||||
await handlers[mimetype](req, res, next);
|
||||
} else {
|
||||
res.status(406).send();
|
||||
next();
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
26
lib/www/server/api/middleware/event/get/json.js
Normal file
26
lib/www/server/api/middleware/event/get/json.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const { transform, prepare } = require('../../../../lib/sse');
|
||||
|
||||
const json = async function (req, res, next) {
|
||||
try {
|
||||
const query = req.query;
|
||||
query.sequence = req.params.sequence;
|
||||
const {events, sequences} = await prepare(req.params.project, query);
|
||||
if ("download" in query || "d" in query) {
|
||||
const extension = "json";
|
||||
// Get the sequence number(s) (more than one sequence can be selected)
|
||||
const seqNums = query.sequence.split(";");
|
||||
// If we've only been asked for a single sequence, get its line name
|
||||
const lineName = (sequences.find(i => i.sequence == seqNums[0]) || {})?.meta?.lineName;
|
||||
const filename = (seqNums.length == 1 && lineName)
|
||||
? `${lineName}-NavLog.${extension}`
|
||||
: `${req.params.project}-${query.sequence}.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.status(200).send(events);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = json;
|
||||
54
lib/www/server/api/middleware/event/get/pdf.js
Normal file
54
lib/www/server/api/middleware/event/get/pdf.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const fs = require('fs/promises');
|
||||
const Path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { configuration } = require('../../../../lib/db');
|
||||
const { transform, prepare } = require('../../../../lib/sse');
|
||||
const render = require('../../../../lib/render');
|
||||
const { url2pdf } = require('../../../../lib/selenium');
|
||||
|
||||
// FIXME Refactor when able
|
||||
const defaultTemplatePath = require('path').resolve(__dirname, "../../../../../../../etc/default/templates/sequence.html.njk");
|
||||
|
||||
function tmpname (tmpdir="/dev/shm") {
|
||||
return Path.join(tmpdir, crypto.randomBytes(16).toString('hex')+".tmp");
|
||||
}
|
||||
|
||||
const pdf = async function (req, res, next) {
|
||||
const fname = tmpname();
|
||||
try {
|
||||
const query = req.query;
|
||||
query.sequence = req.params.sequence;
|
||||
const {events, sequences} = await prepare(req.params.project, query);
|
||||
const seis = transform(events, sequences, {projectId: req.params.project, missingAsEvent: true});
|
||||
const template = (await configuration.get(req.params.project, "sse/templates/0/template")) || defaultTemplatePath;
|
||||
|
||||
const html = await render(seis, template);
|
||||
|
||||
await fs.writeFile(fname, html);
|
||||
const pdf = Buffer.from(await url2pdf("file://"+fname), "base64");
|
||||
|
||||
if ("download" in query || "d" in query) {
|
||||
const extension = "pdf";
|
||||
// Get the sequence number(s) (more than one sequence can be selected)
|
||||
const seqNums = query.sequence.split(";");
|
||||
// If we've only been asked for a single sequence, get its line name
|
||||
const lineName = (sequences.find(i => i.sequence == seqNums[0]) || {})?.meta?.lineName;
|
||||
const filename = (seqNums.length == 1 && lineName)
|
||||
? `${lineName}-NavLog.${extension}`
|
||||
: `${req.params.project}-${query.sequence}.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.status(200).send(pdf);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.message.startsWith("template")) {
|
||||
next({message: err.message});
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
} finally {
|
||||
await fs.unlink(fname);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = pdf;
|
||||
27
lib/www/server/api/middleware/event/get/seis.js
Normal file
27
lib/www/server/api/middleware/event/get/seis.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { transform, prepare } = require('../../../../lib/sse');
|
||||
|
||||
const seis = async function (req, res, next) {
|
||||
try {
|
||||
const query = req.query;
|
||||
query.sequence = req.params.sequence;
|
||||
const {events, sequences} = await prepare(req.params.project, query);
|
||||
const response = transform(events, sequences, {projectId: req.params.project});
|
||||
if ("download" in query || "d" in query) {
|
||||
const extension = "json";
|
||||
// Get the sequence number(s) (more than one sequence can be selected)
|
||||
const seqNums = query.sequence.split(";");
|
||||
// If we've only been asked for a single sequence, get its line name
|
||||
const lineName = (sequences.find(i => i.sequence == seqNums[0]) || {})?.meta?.lineName;
|
||||
const filename = (seqNums.length == 1 && lineName)
|
||||
? `${lineName}-NavLog.${extension}`
|
||||
: `${req.params.project}-${query.sequence}.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.status(200).send(response);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = seis;
|
||||
@@ -1,291 +1,5 @@
|
||||
|
||||
const { event, sequence } = require('../../../../lib/db');
|
||||
|
||||
function transform (events, sequences, opts = {}) {
|
||||
|
||||
const dgl = !!opts.projectId;
|
||||
const exportQC = opts.exportQC !== false;
|
||||
const exportGeneral = opts.exportGeneral !== false;
|
||||
const exportMissing = opts.exportMissing !== false;
|
||||
|
||||
const output = {
|
||||
DglProjectId: opts.projectId,
|
||||
Sequences: [],
|
||||
DglCreatedOn: dgl && new Date()
|
||||
};
|
||||
|
||||
// NOTE: Events come in descending chronological
|
||||
// order from the server.
|
||||
for (const event of events.reverse()) {
|
||||
const SequenceNumber = event.sequence;
|
||||
if (SequenceNumber) {
|
||||
const sequence = sequences.find(s => s.sequence == SequenceNumber);
|
||||
if (!sequence) {
|
||||
throw Error(`Sequence ${SequenceNumber} not found in sequence list`);
|
||||
}
|
||||
|
||||
let SequenceObject = output.Sequences.find(s => s.SequenceNumber == SequenceNumber);
|
||||
if (!SequenceObject) {
|
||||
SequenceObject = {
|
||||
SequenceNumber,
|
||||
Entries: [],
|
||||
DglNumPoints: sequence.num_points,
|
||||
DglNumMissing: sequence.missing_shots,
|
||||
// NOTE: Distance & azimuth refer to raw data if the sequence
|
||||
// status is 'raw' and to final data if status is 'final'. In
|
||||
// the event of it being NTBP it depends on whether final data
|
||||
// exists or not.
|
||||
DglLength: sequence.length
|
||||
};
|
||||
[sequence.remarks, sequence.remarks_final].filter(i => !!i).forEach(i => {
|
||||
if (!SequenceObject.DglSequenceComments) {
|
||||
SequenceObject.DglSequenceComments = []
|
||||
}
|
||||
SequenceObject.DglSequenceComments.push(i);
|
||||
});
|
||||
|
||||
output.Sequences.push(SequenceObject);
|
||||
}
|
||||
|
||||
if (event.labels.includes("FSP")) {
|
||||
const entry = {
|
||||
"EntryType": "Start of line recording",
|
||||
"EntryTypeId": 3000,
|
||||
"Comment": event.remarks.toString(),
|
||||
"Heading": Number(sequence.azimuth.toFixed(1)),
|
||||
"IsReshoot": sequence.reshoot, // FIXME Add this property to sequence object
|
||||
"IsUndershoot": false,
|
||||
// NOTE https://gitlab.com/wgp/dougal/software/-/issues/12#note_419162674
|
||||
"LineNumber": sequence.line.toString(),
|
||||
"LineType": "Prime", // FIXME
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("FGSP")) {
|
||||
const entry = {
|
||||
"EntryType": "Start good",
|
||||
"EntryTypeId": 3001,
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("FCSP")) {
|
||||
const entry = {
|
||||
"EntryType": "Start charged",
|
||||
"EntryTypeId": 3002,
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("LGFFSP")) {
|
||||
const entry = {
|
||||
"EntryType": "Last good Full Fold",
|
||||
"EntryTypeId": 3008,
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("LCFFSP")) {
|
||||
const entry = {
|
||||
"EntryType": "Last charged Full Fold",
|
||||
"EntryTypeId": 3009,
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("FDSP")) {
|
||||
const entry = {
|
||||
"EntryType": "Midnight",
|
||||
"EntryTypeId": 3003,
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("LCSP")) {
|
||||
const entry = {
|
||||
"EntryType": "End charged",
|
||||
"EntryTypeId": 3005,
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("LGSP")) {
|
||||
const entry = {
|
||||
"EntryType": "End good",
|
||||
"EntryTypeId": 3006,
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
if (event.labels.includes("LSP")) {
|
||||
const entry = {
|
||||
"EntryType": "End of line recording",
|
||||
"EntryTypeId": 3007,
|
||||
"Comment": event.remarks.toString(),
|
||||
"IsNTBP": sequence.status == "ntbp",
|
||||
// NOTE: Always pending, see
|
||||
// https://gitlab.com/wgp/dougal/software/-/issues/12
|
||||
"LineStatus": "Pending",
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
|
||||
// Dougal QC data
|
||||
|
||||
if (exportQC) {
|
||||
if (event.labels.includes("QC")) {
|
||||
if (!event.labels.includes("QCAccepted")) {
|
||||
const entry = {
|
||||
"EntryType": "QC",
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exportGeneral) {
|
||||
// These are labels that we have already (potentially) exported
|
||||
const excluded = [
|
||||
"FSP", "FGSP", "FCSP", "LGFFSP",
|
||||
"LCFFSP", "LCSP", "LGSP", "LSP",
|
||||
"LDSP", "FDSP", "QC", "QCAccepted",
|
||||
"Internal", "Private", "Confidential",
|
||||
"NoExport"
|
||||
];
|
||||
if (!event.labels || !event.labels.some( l => excluded.includes(l) )) {
|
||||
const entry = {
|
||||
"Comment": event.remarks.toString(),
|
||||
"ShotPointId": event.point,
|
||||
"Time": event.tstamp
|
||||
}
|
||||
SequenceObject.Entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
for (const SequenceObject of output.Sequences) {
|
||||
const sequence = sequences.find(s => s.sequence == SequenceObject.SequenceNumber);
|
||||
|
||||
// If no explicit FSP but there is a FGSP, clone it as FSP.
|
||||
if (!SequenceObject.Entries.find(i => i.EntryTypeId == 3000)) {
|
||||
const fgspIndex = SequenceObject.Entries.findIndex(i => i.EntryTypeId == 3001);
|
||||
if (fgspIndex != -1) {
|
||||
const fsp = Object.assign({}, SequenceObject.Entries[fgspIndex], {
|
||||
"EntryType": "Start of line recording",
|
||||
"EntryTypeId": 3000,
|
||||
"Heading": Number(sequence.azimuth.toFixed(1)),
|
||||
"IsReshoot": sequence.reshoot,
|
||||
"IsUndershoot": false,
|
||||
"LineNumber": sequence.line.toString(),
|
||||
"LineType": "Prime", // FIXME
|
||||
});
|
||||
SequenceObject.Entries.splice(fgspIndex, 0, fsp);
|
||||
} else {
|
||||
// We have neither an FSP nor an FGSP, let us take the first
|
||||
// preplot shot from the sequence data as save that as FSP
|
||||
// (we intentionally omit adding an FGSP as we know nothing
|
||||
// about the user's intent).
|
||||
const fsp = {
|
||||
"EntryType": "Start of line recording",
|
||||
"EntryTypeId": 3000,
|
||||
"Comment": sequence.fsp_final
|
||||
? "(First preplot shot found in P1)"
|
||||
: "(First shot found in P1)",
|
||||
"Heading": Number(sequence.azimuth.toFixed(1)),
|
||||
"IsReshoot": sequence.reshoot, // FIXME Add this property to sequence object
|
||||
"IsUndershoot": false,
|
||||
// NOTE https://gitlab.com/wgp/dougal/software/-/issues/12#note_419162674
|
||||
"LineNumber": sequence.line.toString(),
|
||||
"LineType": "Prime", // FIXME
|
||||
"ShotPointId": sequence.fsp_final || sequence.fsp,
|
||||
"Time": sequence.ts0_final || sequence.ts0
|
||||
}
|
||||
SequenceObject.Entries.unshift(fsp);
|
||||
}
|
||||
}
|
||||
|
||||
// If no explicit LSP but there is a LGSP, clone it as LSP.
|
||||
if (!SequenceObject.Entries.find(i => i.EntryTypeId == 3007)) {
|
||||
const lgspIndex = SequenceObject.Entries.findIndex(i => i.EntryTypeId == 3006);
|
||||
if (lgspIndex != -1) {
|
||||
const lsp = Object.assign({}, SequenceObject.Entries[lgspIndex], {
|
||||
"EntryType": "End of line recording",
|
||||
"EntryTypeId": 3007,
|
||||
"IsNTBP": sequence.status == "ntbp",
|
||||
"LineStatus": "Pending",
|
||||
});
|
||||
SequenceObject.Entries.splice(lgspIndex+1, 0, lsp);
|
||||
} else {
|
||||
// See comment above concerning FSP
|
||||
const lsp = {
|
||||
"EntryType": "End of line recording",
|
||||
"EntryTypeId": 3007,
|
||||
"Comment": sequence.lsp_final
|
||||
? "(Last preplot shot found in P1)"
|
||||
: "(Last shot found in P1)",
|
||||
"IsNTBP": sequence.status == "ntbp",
|
||||
// NOTE: Always pending, see
|
||||
// https://gitlab.com/wgp/dougal/software/-/issues/12
|
||||
"LineStatus": "Pending",
|
||||
"ShotPointId": sequence.lsp_final || sequence.lsp,
|
||||
"Time": sequence.ts1_final || sequence.ts1
|
||||
}
|
||||
SequenceObject.Entries.push(lsp);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the missing shots object if not inhibited by the user.
|
||||
if (exportMissing) {
|
||||
// The user also needs to request missing shots from the sequences
|
||||
// endpoint; these are not returned by default.
|
||||
if ("missing_final" in sequence) {
|
||||
SequenceObject.MissingShots = sequence.missing_final.map(s => s.point).sort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
const { transform } = require('../../../../lib/sse');
|
||||
|
||||
const seis = async function (req, res, next) {
|
||||
try {
|
||||
|
||||
@@ -11,5 +11,7 @@ module.exports = {
|
||||
navdata: require('./navdata'),
|
||||
configuration: require('./configuration'),
|
||||
info: require('./info'),
|
||||
meta: require('./meta')
|
||||
meta: require('./meta'),
|
||||
openapi: require('./openapi'),
|
||||
rss: require('./rss')
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
const { info } = require('../../../lib/db');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
await info.delete(req.params.project, req.params.path);
|
||||
res.status(204).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
const { info } = require('../../../lib/db');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
await info.post(req.params.project, req.params.path, payload);
|
||||
res.status(201).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
const { info } = require('../../../lib/db');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
await info.put(req.params.project, req.params.path, payload);
|
||||
res.status(201).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports = async function (req, res, next) {
|
||||
const payload = req.body;
|
||||
|
||||
await line.patch(req.params.project, req.params.line, payload);
|
||||
res.status(201).send();
|
||||
res.status(204).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
44
lib/www/server/api/middleware/openapi/get.js
Normal file
44
lib/www/server/api/middleware/openapi/get.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const YAML = require('yaml');
|
||||
|
||||
const openapiYAML = path.join(__dirname, "../../../spec/openapi.yaml");
|
||||
const openapiHTML = path.join(__dirname, "../../../spec/openapi.html");
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
function handleError (err) {
|
||||
if (err instanceof TypeError || err.code == "ENOENT") {
|
||||
res.status(404).send();
|
||||
next();
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.accepts("text/html")) {
|
||||
const stream = fs.createReadStream(openapiHTML);
|
||||
stream.on('open', () => {
|
||||
res.set("Content-Type", "text/html");
|
||||
stream.pipe(res);
|
||||
});
|
||||
stream.on('end', next);
|
||||
stream.on('error', handleError);
|
||||
} else if (req.accepts("application/json")) {
|
||||
const text = await fs.promises.readFile(openapiYAML, 'utf8');
|
||||
res.json(YAML.parse(text));
|
||||
next();
|
||||
} else {
|
||||
const stream = fs.createReadStream(openapiYAML);
|
||||
stream.on('open', () => {
|
||||
res.set("Content-Type", "application/yaml");
|
||||
stream.pipe(res);
|
||||
});
|
||||
stream.on('end', next);
|
||||
stream.on('error', handleError);
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
};
|
||||
4
lib/www/server/api/middleware/openapi/index.js
Normal file
4
lib/www/server/api/middleware/openapi/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
module.exports = {
|
||||
get: require('./get')
|
||||
}
|
||||
@@ -7,7 +7,7 @@ module.exports = async function (req, res, next) {
|
||||
const payload = req.body;
|
||||
|
||||
await plan.delete(req.params.project, req.params.sequence);
|
||||
res.status(201).send();
|
||||
res.status(204).send();
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
42
lib/www/server/api/middleware/plan/list/csv.js
Normal file
42
lib/www/server/api/middleware/plan/list/csv.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { AsyncParser } = require('json2csv');
|
||||
const { plan } = require('../../../../lib/db');
|
||||
|
||||
const json = async function (req, res, next) {
|
||||
try {
|
||||
const response = await plan.list(req.params.project, req.query);
|
||||
|
||||
if ("download" in req.query || "d" in req.query) {
|
||||
const extension = "html";
|
||||
const filename = `${req.params.project.toUpperCase()}-Plan.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
|
||||
const transforms = (i) => {
|
||||
i.lon0 = Number(((i?.geometry?.coordinates||[])[0]||[])[0]).toFixed(6)*1;
|
||||
i.lat0 = Number(((i?.geometry?.coordinates||[])[0]||[])[1]).toFixed(6)*1;
|
||||
i.lon1 = Number(((i?.geometry?.coordinates||[])[1]||[])[0]).toFixed(6)*1;
|
||||
i.lat1 = Number(((i?.geometry?.coordinates||[])[1]||[])[1]).toFixed(6)*1;
|
||||
i.duration = i.duration?.hours*3600 + i.duration?.minutes*60 + i.duration?.seconds;
|
||||
delete i.class;
|
||||
delete i.geometry;
|
||||
delete i.meta;
|
||||
return i;
|
||||
};
|
||||
|
||||
const csv = new AsyncParser({transforms}, {objectMode: true});
|
||||
csv.processor.on('error', (err) => { throw err; });
|
||||
csv.processor.on('end', () => {
|
||||
res.end();
|
||||
next();
|
||||
});
|
||||
|
||||
res.status(200);
|
||||
csv.processor.pipe(res);
|
||||
response.forEach(row => csv.input.push(row));
|
||||
csv.input.push(null);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = json;
|
||||
83
lib/www/server/api/middleware/plan/list/html.js
Normal file
83
lib/www/server/api/middleware/plan/list/html.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// const { configuration } = require('../../../../lib/db');
|
||||
const { plan, gis, info } = require('../../../../lib/db');
|
||||
const leafletMap = require('../../../../lib/map');
|
||||
const render = require('../../../../lib/render');
|
||||
|
||||
// FIXME Refactor when able
|
||||
const defaultTemplatePath = require('path').resolve(__dirname, "../../../../../../../etc/default/templates/plan.html.njk");
|
||||
|
||||
const html = async function (req, res, next) {
|
||||
try {
|
||||
const planInfo = await info.get(req.params.project, "plan", req.query);
|
||||
const lines = await plan.list(req.params.project, req.query);
|
||||
const preplotGeoJSON = await gis.project.preplot.lines(req.params.project, {class: "V", ...req.query});
|
||||
const linesGeoJSON = lines.filter(plan => plan.geometry).map(plan => {
|
||||
const feature = {
|
||||
type: "Feature",
|
||||
geometry: plan.geometry,
|
||||
properties: plan
|
||||
};
|
||||
delete feature.properties.geometry;
|
||||
return feature;
|
||||
});
|
||||
|
||||
// const template = (await configuration.get(req.params.project, "sse/templates/0/template")) || defaultTemplatePath;
|
||||
const template = defaultTemplatePath;
|
||||
|
||||
const mapConfig = {
|
||||
size: { width: 500, height: 500 },
|
||||
layers: [
|
||||
{
|
||||
features: preplotGeoJSON,
|
||||
options: {
|
||||
style (feature) {
|
||||
return {
|
||||
opacity: feature.properties.ntba ? 0.2 : 0.5,
|
||||
color: "gray",
|
||||
weight: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
features: linesGeoJSON,
|
||||
options: {
|
||||
style (feature) {
|
||||
return {
|
||||
color: "magenta",
|
||||
weight: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const map = leafletMap(mapConfig);
|
||||
|
||||
const data = {
|
||||
projectId: req.params.project,
|
||||
info: planInfo,
|
||||
lines,
|
||||
map: await map.getImageData()
|
||||
}
|
||||
|
||||
const response = await render(data, template);
|
||||
|
||||
if ("download" in req.query || "d" in req.query) {
|
||||
const extension = "html";
|
||||
const filename = `${req.params.project.toUpperCase()}-Plan.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.status(200).send(response);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.message.startsWith("template")) {
|
||||
next({message: err.message});
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = html;
|
||||
@@ -1,14 +1,20 @@
|
||||
const json = require('./json');
|
||||
const geojson = require('./geojson');
|
||||
const html = require('./html');
|
||||
const pdf = require('./pdf');
|
||||
const csv = require('./csv');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
try {
|
||||
const handlers = {
|
||||
"application/json": json,
|
||||
"application/geo+json": geojson,
|
||||
"text/csv": csv,
|
||||
"text/html": html,
|
||||
"application/pdf": pdf
|
||||
};
|
||||
|
||||
const mimetype = req.accepts(Object.keys(handlers));
|
||||
const mimetype = (handlers[req.query.mime] && req.query.mime) || req.accepts(Object.keys(handlers));
|
||||
|
||||
if (mimetype) {
|
||||
res.set("Content-Type", mimetype);
|
||||
|
||||
97
lib/www/server/api/middleware/plan/list/pdf.js
Normal file
97
lib/www/server/api/middleware/plan/list/pdf.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const fs = require('fs/promises');
|
||||
const Path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { configuration } = require('../../../../lib/db');
|
||||
const { plan, gis, info } = require('../../../../lib/db');
|
||||
const leafletMap = require('../../../../lib/map');
|
||||
const render = require('../../../../lib/render');
|
||||
const { url2pdf } = require('../../../../lib/selenium');
|
||||
|
||||
// FIXME Refactor when able
|
||||
const defaultTemplatePath = require('path').resolve(__dirname, "../../../../../../../etc/default/templates/plan.html.njk");
|
||||
|
||||
function tmpname (tmpdir="/dev/shm") {
|
||||
return Path.join(tmpdir, crypto.randomBytes(16).toString('hex')+".tmp");
|
||||
}
|
||||
|
||||
const pdf = async function (req, res, next) {
|
||||
const fname = tmpname();
|
||||
try {
|
||||
const planInfo = await info.get(req.params.project, "plan", req.query);
|
||||
const lines = await plan.list(req.params.project, req.query);
|
||||
const preplotGeoJSON = await gis.project.preplot.lines(req.params.project, {class: "V", ...req.query});
|
||||
const linesGeoJSON = lines.filter(plan => plan.geometry).map(plan => {
|
||||
const feature = {
|
||||
type: "Feature",
|
||||
geometry: plan.geometry,
|
||||
properties: plan
|
||||
};
|
||||
delete feature.properties.geometry;
|
||||
return feature;
|
||||
});
|
||||
// const template = (await configuration.get(req.params.project, "sse/templates/0/template")) || defaultTemplatePath;
|
||||
const template = defaultTemplatePath;
|
||||
|
||||
|
||||
const mapConfig = {
|
||||
size: { width: 500, height: 500 },
|
||||
layers: [
|
||||
{
|
||||
features: preplotGeoJSON,
|
||||
options: {
|
||||
style (feature) {
|
||||
return {
|
||||
opacity: feature.properties.ntba ? 0.2 : 0.5,
|
||||
color: "gray",
|
||||
weight: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
features: linesGeoJSON,
|
||||
options: {
|
||||
style (feature) {
|
||||
return {
|
||||
color: "magenta",
|
||||
weight: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const map = leafletMap(mapConfig);
|
||||
|
||||
const data = {
|
||||
projectId: req.params.project,
|
||||
info: planInfo,
|
||||
lines,
|
||||
map: await map.getImageData()
|
||||
}
|
||||
|
||||
const html = await render(data, template);
|
||||
|
||||
await fs.writeFile(fname, html);
|
||||
const pdf = Buffer.from(await url2pdf("file://"+fname), "base64");
|
||||
|
||||
if ("download" in req.query || "d" in req.query) {
|
||||
const extension = "pdf";
|
||||
const filename = `${req.params.project.toUpperCase()}-Plan.${extension}`;
|
||||
res.set("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
}
|
||||
res.status(200).send(pdf);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.message.startsWith("template")) {
|
||||
next({message: err.message});
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
} finally {
|
||||
await fs.unlink(fname);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = pdf;
|
||||
21
lib/www/server/api/middleware/rss/get.js
Normal file
21
lib/www/server/api/middleware/rss/get.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
if (req.query.remote) {
|
||||
// We're being asked to fetch a remote feed
|
||||
// NOTE: No, we don't limit what feeds the user can fetch
|
||||
|
||||
const r = await fetch(req.query.remote);
|
||||
if (r && r.ok) {
|
||||
res.set("Content-Type", "application/xml");
|
||||
res.send(await r.text());
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.status(400).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
4
lib/www/server/api/middleware/rss/index.js
Normal file
4
lib/www/server/api/middleware/rss/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
module.exports = {
|
||||
get: require('./get')
|
||||
}
|
||||
28
lib/www/server/api/middleware/sequence/get/geojson.js
Normal file
28
lib/www/server/api/middleware/sequence/get/geojson.js
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
const { sequence } = require('../../../../lib/db');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
const json = await sequence.get(req.params.project, req.params.sequence, req.query);
|
||||
const geometry = req.query.geometry || "geometrypreplot";
|
||||
|
||||
const geojson = {
|
||||
type: "FeatureCollection",
|
||||
features: json.map(feature => {
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: feature[geometry],
|
||||
properties: {...feature}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
res.status(200).send(geojson);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
23
lib/www/server/api/middleware/sequence/get/index.js
Normal file
23
lib/www/server/api/middleware/sequence/get/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const json = require('./json');
|
||||
const geojson = require('./geojson');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
try {
|
||||
const handlers = {
|
||||
"application/json": json,
|
||||
"application/geo+json": geojson,
|
||||
};
|
||||
|
||||
const mimetype = (handlers[req.query.mime] && req.query.mime) || req.accepts(Object.keys(handlers));
|
||||
|
||||
if (mimetype) {
|
||||
res.set("Content-Type", mimetype);
|
||||
await handlers[mimetype](req, res, next);
|
||||
} else {
|
||||
res.status(406).send();
|
||||
next();
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
14
lib/www/server/api/middleware/sequence/get/json.js
Normal file
14
lib/www/server/api/middleware/sequence/get/json.js
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
const { sequence } = require('../../../../lib/db');
|
||||
|
||||
module.exports = async function (req, res, next) {
|
||||
|
||||
try {
|
||||
res.status(200).send(await sequence.get(req.params.project, req.params.sequence, req.query));
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
exports.login = require('./login');
|
||||
exports.logout = require('./logout');
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const crypto = require('crypto');
|
||||
const cfg = require('../../../lib/config');
|
||||
const jwt = require('../../../lib/jwt');
|
||||
|
||||
async function login (req, res, next) {
|
||||
if (req.body) {
|
||||
const {user, password} = req.body;
|
||||
if (user && password) {
|
||||
const hash = crypto
|
||||
.pbkdf2Sync(password, 'Dougal'+user, 10712, 48, 'sha512')
|
||||
.toString('base64');
|
||||
for (const credentials of cfg._("global.users.login.user") || []) {
|
||||
if (credentials.name == user && credentials.hash == hash) {
|
||||
const payload = Object.assign({}, credentials);
|
||||
delete payload.hash;
|
||||
jwt.issue(payload, req, res);
|
||||
res.status(204).send();
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
next({status: 401, message: "Unauthorised"});
|
||||
}
|
||||
}
|
||||
next({status: 400, message: "Bad request"});
|
||||
}
|
||||
|
||||
module.exports = login;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
async function logout (req, res, next) {
|
||||
res.clearCookie("JWT");
|
||||
res.status(204).send();
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = logout;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user