Compare commits

..

231 Commits

Author SHA1 Message Date
D. Berge
57a08c93bc Add link to graphics tab from sequence list 2021-09-28 22:16:12 +02:00
D. Berge
fabc9fe757 Do not make graphs editable 2021-09-28 18:30:26 +02:00
D. Berge
6f32f24481 Add configuration dialog to Graphs.
Lets the user choose which aspects (graphs) he wants to
be visible.
2021-09-28 18:17:38 +02:00
D. Berge
dffe7defbb Add tooltips to Graphs toolbar 2021-09-28 18:16:57 +02:00
D. Berge
b9844528f1 Add graphBar to resizeObserver.
This ensures that it is always the right size when it first
gets displayed.
2021-09-28 18:15:19 +02:00
D. Berge
cd78dbd0d8 Fix typos in resizeObserver 2021-09-28 18:14:39 +02:00
D. Berge
798203be9f Add preferences support to DougalGraphGunsPressure 2021-09-28 18:12:37 +02:00
D. Berge
5bfd7dc835 Add preferences support to DougalGraphGunsDepth 2021-09-28 18:11:43 +02:00
D. Berge
c17862fbbb Add preferences support to DougalGraphGunsTiming 2021-09-28 18:11:04 +02:00
D. Berge
04c0369923 Add preferences support to DougalGraphArraysIJScatter 2021-09-28 18:10:08 +02:00
D. Berge
026cfb6f98 Rename GraphArraysIJScatter to DougalGraphArraysIJScatter 2021-09-28 18:08:48 +02:00
D. Berge
a4e6ec0712 Add support for personalising QC graph settings.
Preferences are read from the store and passed to graph components
via the `settings` prop. Component may changed their own settings
by emitting the `update:settings` signal.
2021-09-28 17:59:32 +02:00
D. Berge
b3e052cb12 Add utility function to filter preferences by a prefix 2021-09-28 17:53:07 +02:00
D. Berge
cf88ecf172 Save user preferences to Vuex store.
The user preferences are saved in the browser's localStorage and
read by setCredentials() whenever that function is called. From
that point they are cached in the Vuex store.

Provided that preferences are only modified through the store,
via the saveUserPreference() call, the preferences should always
be in sync between the store and the browser.

The preferences object is a key/value store. Each key is
expected to be in the form of a series of dot-separated prefixes,
e.g., `UserX.RoleY.Graphs.GraphType1.setting0`.

For user preferences, the first two prefix elements should be the
username and role of the user that the setting applies to. These will
be automatically added and stripped by saveUserPreference() and
loadUserPreferences() respectively.
2021-09-28 17:42:49 +02:00
D. Berge
e267440711 Move comment to right place 2021-09-28 17:30:48 +02:00
D. Berge
454094b187 Refactor gun heatmaps component.
Fixes #150.

Contributes towards the goal of #149. As irrelevant data (such
as for non-firing guns) is no longer shown at all. This affects:

* Firetime (only active array data shown)
* Gun deltas (only active array shown)
* Fill time (only non-active array shown)
2021-09-21 00:32:00 +02:00
D. Berge
862e754a6f Fix labelling of gun mode and detect heatmaps.
Fixes #142.
2021-09-20 00:18:31 +02:00
D. Berge
894877750e Make heatmap hover box more informative.
Closes #143.
2021-09-20 00:17:35 +02:00
D. Berge
09b45d5d65 Swap outlier colours 2021-09-11 21:30:12 +02:00
D. Berge
1352c3b312 Make graph colours consistent for port / starboard elements 2021-09-11 19:19:58 +02:00
D. Berge
30aa2c302e Add graphic aesthetics 2021-09-11 12:38:12 +02:00
D. Berge
3eaa2757b9 Add Graphs tab to navigation bar 2021-09-11 12:19:06 +02:00
D. Berge
6f6af1bbc7 Add graphs/ route to client 2021-09-11 12:19:06 +02:00
D. Berge
019561229c Add Graph component.
It displays a series of data plots.
2021-09-11 12:19:06 +02:00
D. Berge
e212dc8b92 Add unpack helper function to frontend.
Convenience function to extract a key from an
array of objects.
2021-09-11 12:19:06 +02:00
D. Berge
5c00013892 Add graphic library dependencies 2021-09-11 12:19:06 +02:00
D. Berge
1e5bdcc068 Add Vuex functions to load / save user preferences 2021-09-11 12:19:06 +02:00
D. Berge
a280a910f5 Add database upgrade file 07 2021-09-11 12:19:06 +02:00
D. Berge
45fe467a21 Implement sequence/get API endpoint.
It returns data for all individual points in a sequence.
2021-09-11 12:19:06 +02:00
D. Berge
8d3b7adc78 Show azimuths to two decimals in SeisJSON exports 2021-09-04 23:34:53 +02:00
D. Berge
079d3a18b0 Merge branch '131-show-missing-shots-in-sequence-reports' into 'devel'
Resolve "Show missing shots in sequence reports"

Closes #131

See merge request wgp/dougal/software!15
2021-09-04 21:32:44 +00:00
D. Berge
f0b1fc2fe6 Show missed shot events in HTML, PDF exports 2021-09-04 23:29:58 +02:00
D. Berge
987bdf6e21 Add option to export missing shots as SeisJSON events 2021-09-04 23:28:43 +02:00
D. Berge
1d3507b3a4 Export missing shots by default.
Unless explicitly requested by the user by setting the
option `missing` to `false`, a list of missing shotpoints
will be included in the SeisJSON file.
2021-09-04 23:19:25 +02:00
D. Berge
a82fc7bc8a Recover from feed XML parsing error 2021-09-04 02:43:58 +02:00
D. Berge
29b3c9a250 Show azimuth to two decimals elsewhere too.
Related to #126, might as well use two decimals throughout.
2021-09-02 01:18:47 +02:00
D. Berge
040c1ead96 Show azimuth to two decimal places.
In planner report template.

Closes #126.
2021-09-02 01:17:40 +02:00
D. Berge
1c7bed0c15 Fix returning next planned sequence number.
If no sequences have been shot, return 1 instead of null as the
next available sequence number.

Fixes #125.
2021-09-02 01:04:38 +02:00
D. Berge
dfcda1b2d9 Merge branch '103-24-hour-lookahead-planning-report' into 'devel'
Resolve "24-hour lookahead planning report"

Closes #103

See merge request wgp/dougal/software!13
2021-06-21 14:53:35 +00:00
D. Berge
b3aadfc33c Merge branch '60-update-planner-as-sequences-are-shot' into 'devel'
Resolve "Update planner as sequences are shot"

Closes #60

See merge request wgp/dougal/software!12
2021-06-21 14:52:11 +00:00
D. Berge
d5980d9154 Add CSV planner output option 2021-06-19 19:04:05 +02:00
D. Berge
b5f2945c8b Fix end time in plan HTML template 2021-06-19 15:43:04 +02:00
D. Berge
9bbffe2ae0 React to changes in planner remarks 2021-06-19 12:27:36 +02:00
D. Berge
09f60d6c18 Add database upgrade file 06 2021-06-19 12:23:25 +02:00
D. Berge
81d9ea19cc Add adjust_planner() function to DB schema.
It updates the planned lines details according to production and current
time.
2021-06-19 12:18:28 +02:00
D. Berge
497d4d68f9 Call notify on changes to schema's info table 2021-06-19 12:17:26 +02:00
D. Berge
853deca3c3 Rename misnamed trigger 2021-06-19 12:16:37 +02:00
D. Berge
99f1530db3 Replace phone icon in template.
Strangely enough, the emoji icon seems to work reliably across
platforms.
2021-05-31 02:54:38 +02:00
D. Berge
b325ae3452 Let the user know when there are no planner comments 2021-05-31 02:47:20 +02:00
D. Berge
f97d334fe5 Improve the aesthetics of the planner remarks section 2021-05-31 02:41:58 +02:00
D. Berge
cb114f01cd Add GUI support for downloading planner data.
Including HTML and PDF formats, which constitutes the lookahead report.
2021-05-31 02:29:50 +02:00
D. Berge
707df76b70 Add GUI support for saving planner remarks.
They get saved to `/project/:project/info/plan/remarks`.
2021-05-31 02:29:50 +02:00
D. Berge
bba050032f Add POST, PUT, DELETE support to /project/:project/info.
It reuses the same backend functions as for the global `/info/` path.
2021-05-31 02:29:50 +02:00
D. Berge
594233c965 Add HTML & PDF planner output options.
Coupled with a suitable Nunjucks template, this is effectively the
24-hour (or whatever period of time) lookahead.
2021-05-31 02:29:50 +02:00
D. Berge
5795c1f87d Add server-side map rendering component.
Based on our own fork of leaflet-headless.
2021-05-31 02:29:50 +02:00
D. Berge
ccd1852f65 Add Nunjucks rendered get filter.
Given an argument consisting of an array of objects and an attribute
name `attr`, it returns an array of all `attr` attributes.
2021-05-31 02:29:50 +02:00
D. Berge
17947df168 Modify Nunjucks rendered timestamp function.
* It accepts a `precision` parameter which truncates the timestamp to a
give precision. Can be `seconds`, `minutes`, `hours` or `days` / `date`.

* It tries to be more flexible in what it accepts as input.

* It accepts an input of "now" which returns the current timestamp. Can
  be used along with `precision`.
2021-05-31 02:29:50 +02:00
D. Berge
041878096d Accept a mime query parameter to force MIME type 2021-05-31 02:29:50 +02:00
D. Berge
ea3e31058f Refactor the planned lines editing logic.
We move most of the logic from the client (as it was until now) to the
server.

The PATCH command maintains the same format but it should provide only
one of the following keys per request:

* ts0
* ts1
* speed
* fsp
* lsp
* lagAfter
* sequence

   Earlier keys in the list above take priority over latter ones.

The following keys may be provided by themselves or in combination with
each other (but not with any of the above):

* name
* remarks
* meta

As a special case, an empty string as the `name` value causes the name
to be auto-generated.

See comments in the code `patch.js` for details on the update logic.
2021-05-28 20:30:59 +02:00
D. Berge
534a54ef75 Add database upgrade file 05 2021-05-28 20:30:59 +02:00
D. Berge
f314536daf Change planned_lines trigger from statement to row.
Because a) it tells us what has changed and b) doesn't fire if we
didn't actually change anything.
2021-05-28 20:30:59 +02:00
D. Berge
de4aa52417 Make planned_lines primary key deferrable.
Helps when we need to renumber sequences.
2021-05-28 20:30:59 +02:00
D. Berge
758b13b189 Add saillines layer to map 2021-05-28 20:30:29 +02:00
D. Berge
967db1dec6 Include NTBA status in preplot GIS output 2021-05-28 20:29:57 +02:00
D. Berge
91fd5e4559 Ensure that timestamp is always a Date object 2021-05-27 17:50:01 +02:00
D. Berge
cf171628cd Fix error in editing of planned line start time 2021-05-27 17:49:32 +02:00
D. Berge
94c29f4723 Change the sunset / sunrise times reported via the tooltip.
The icon still uses the lower edge of the sun to calculate day / night,
but the tooltip shows actual sunrise and sunset times.
2021-05-27 02:08:30 +02:00
D. Berge
14b2e55a2e Remove edit controls from planner for read-only users.
Left over from #108.
2021-05-27 01:32:03 +02:00
D. Berge
c30e54a515 Round vessel speeds to 0.1 kt 2021-05-27 01:09:28 +02:00
D. Berge
7ead826677 Show sunrise / sunset times in the planner.
* A ‘sun’ icon is shown when a line starts and ends in daytime
* A ‘moon’ icon is shown when a line starts and ends in nighttime
* A ‘sun/moon’ icon is shown in other cases

Sunrise and sunset times are provided as a tooltip when hovering over
the icon.

Closes #72.
2021-05-27 01:02:42 +02:00
D. Berge
7aecb514db Clear QC metadata when importing gun data.
Fixes #118.
2021-05-26 00:30:58 +02:00
D. Berge
ad395aa6e4 Include the planned lines table in system dumps 2021-05-26 00:15:09 +02:00
D. Berge
523ec937dd Always merge metadata on import.
The INSERT INTO raw_lines / final_lines will not always be executed as
the lines may already exist (particularly in raw_lines because of
*online*), so whether it worked or not we merge the metadata immediately
afterwards (this may cause an extra notification to be fired).
2021-05-25 03:19:42 +02:00
D. Berge
9d2ccd75dd Do not try to use line name if there isn't one 2021-05-25 03:19:00 +02:00
D. Berge
3985a6226b Suggest ${lineName}-NavLog.${extension} as file name.
This is for the usual case where only one sequence is requested.

When more than one sequence is requested, the suggested name comes out
as ${projectId}-${sequenceList}.${extension}, where `sequenceList` is
the list of sequence numbers separated by semicolons, e.g.:
eq21203-37;38;39.html.

Closes #116.
2021-05-25 02:23:41 +02:00
D. Berge
7d354ffdb6 Add database upgrade file 2021-05-25 02:21:11 +02:00
D. Berge
3d70a460ac Output raw and final lines metadata in summary views 2021-05-25 02:13:50 +02:00
D. Berge
caae656aae Fix event detection failure.
There was a typo in the channel detection logic, resulting
in bogus events full of `undefined` data values.

Fixes #115.
2021-05-24 18:30:53 +02:00
D. Berge
5708ed1a11 Merge branch '57-make-event-log-entries-for-start-and-end-of-line-upon-import-of-final-sequence-if-the-entries-do' into 'devel'
Resolve "Make event log entries for start and end of line upon import of final sequence, if the entries do not already exist"

Closes #57

See merge request wgp/dougal/software!11
2021-05-24 15:44:58 +00:00
D. Berge
ad3998d4c6 Add database upgrade file 2021-05-24 17:41:11 +02:00
D. Berge
8638f42e6d Add database upgrade files.
These files contain the sequence of SQL commands needed to bring
a database or project schema up to date with the latest template
database or project schema.

These files must be applied manually. Check the comments at the top of
the file for instructions.
2021-05-24 17:39:01 +02:00
D. Berge
bc5aef5144 Run post-import functions after final lines.
The reason why need to do it like this instead of relying on a trigger
is because the entry in final_lines is created first and the final_shots
are populated. If we first the trigger on final_lines it is not going
to find any shots; if we fire it as a row trigger on final_shots it
would try to label every point in sequence as it is imported; finally if
we fire it as a statement trigger on final_shots we have no idea which
sequence was imported.
2021-05-24 16:59:56 +02:00
D. Berge
2b798c3ea3 Ignore attempts to put the same label twice on the same event 2021-05-24 16:59:20 +02:00
D. Berge
4d97784829 Upgrade database project schema template.
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().
2021-05-24 16:49:39 +02:00
D. Berge
13da38b4cd Make websocket notifications await.
Not sure if this helps much. It might help with avoiding
out of order notifications and reducing the rate at which
the clients get spammed when importing database dumps and
such, but that hasn't been tested.
2021-05-24 15:52:29 +02:00
D. Berge
5af89050fb Refactor SOL/EOL real-time detection handler.
This also implements a generic handler mechanism that can be
reused for other purposes, such as sending email / XMPP notifications,
doing real-time QC checks and so on.

Fixes #113.
2021-05-24 13:48:53 +02:00
D. Berge
d40ceb8343 Refactor list of notification channels into its own file 2021-05-24 13:38:19 +02:00
D. Berge
56d1279584 Allow api action to make arbitrary HTTP(S) requests.
If the URL is an absolute HTTP(S) one, we use it as-is.
2021-05-24 13:35:36 +02:00
D. Berge
d02edb4e76 Force the argument into String prior to splitting 2021-05-24 13:32:03 +02:00
D. Berge
9875ae86f3 Record P1/11 line name in database on import 2021-05-24 13:30:25 +02:00
D. Berge
53f71f7005 Set primary key on events_seq_labels in schema template 2021-05-23 22:27:00 +02:00
D. Berge
5de64e6b45 Add meta column to events view in schema template 2021-05-23 22:26:00 +02:00
D. Berge
67af85eca9 Recognise PENDING status in sequence imports.
If a final sequence file or directory name matches a pattern
which is recognised to indicate a ‘pending acceptance’ status,
the final data (if any exists) for that sequence will be deleted
and a comment added to the effect that the sequence has been
marked as ‘pending’.

To accept the sequence, rename its final file or directory name
accordingly.

Note: it is the *final* data that is searched for a matching
pattern, not the raw.

Closes #91.
2021-05-21 15:15:15 +02:00
D. Berge
779b28a331 Add info table to system dumps 2021-05-21 12:18:36 +02:00
D. Berge
b9a4d18ed9 Do not fail if no equipment has been defined.
Fixes #112.
2021-05-20 21:16:39 +02:00
D. Berge
0dc9ac2b3c Merge branch '71-add-equipment-info-to-the-logs' into 'devel'
Resolve "Add equipment info to the logs"

Closes #71

See merge request wgp/dougal/software!10
2021-05-20 19:05:35 +00:00
D. Berge
39d85a692b Use default Nunjucks template if necessary.
If the survey configuration does not itself have a template
we will use the one in etc/defaults/templates/sequence.html.njk.

It is not very likely that the template will be changed all that
often and it avoids issues when people forget to copy it across
to a new survey, etc.
2021-05-20 20:38:39 +02:00
D. Berge
e7661bfd1c Do not fail if requested object does not exist 2021-05-20 20:38:08 +02:00
D. Berge
1649de6c68 Update default sequence HTML template 2021-05-20 20:37:37 +02:00
D. Berge
1089d1fe75 Add equipment configuration fontend user interface 2021-05-20 18:35:56 +02:00
D. Berge
fc58a4d435 Implement equipment frontend component 2021-05-20 18:35:56 +02:00
D. Berge
c832d8b107 Commit default template for sequences 2021-05-20 18:35:56 +02:00
D. Berge
4a9e61be78 Add unique filter to Nunjucks renderer 2021-05-20 18:35:56 +02:00
D. Berge
8cfd1a7fc9 Export equipment info to Seis+JSON files 2021-05-20 18:35:56 +02:00
D. Berge
315733eec0 Refactor events export middleware.
Uses the `prepare` method for better reusability.
2021-05-20 18:35:56 +02:00
D. Berge
ad422abe94 Add prepare method for Seis+JSON and related exports.
It retrieves the data necessary for a complete Seis+JSON
export, including equipment info.
2021-05-20 18:35:56 +02:00
D. Berge
92210378e1 Listen for and broadcast info notifications 2021-05-20 18:21:01 +02:00
D. Berge
8d3e665206 Expose new API endpoint: /info/:path(*).
Provides CRUD access to values (which may be deeply nested) from the
global `info` table.
2021-05-20 18:19:29 +02:00
D. Berge
4ee65ef284 Implement info/delete middleware 2021-05-20 18:18:26 +02:00
D. Berge
d048a19066 Implement info/put middleware 2021-05-20 18:18:13 +02:00
D. Berge
97ed9bcce4 Implement info/post middleware 2021-05-20 18:17:52 +02:00
D. Berge
316117cb83 Implement info.delete() database method.
It deletes a (possibly deeply nested) element in the
`info` table.
2021-05-20 18:16:26 +02:00
D. Berge
1d38f6526b Implement info.put() database method.
Replaces an existing element with a new one, or inserts it
if there is nothing to replace. The element may be deeply
nested inside a JSON object or array in the `info` table.

Works for both public.info and survey_?.info.
2021-05-20 18:14:43 +02:00
D. Berge
6feb7d49ee Implement info.post() database method.
It adds an element to a JSON array corresponding to a
key in the info table. Errors out if the value is not
an array.
2021-05-20 18:13:15 +02:00
D. Berge
ac51f72180 Ignore empty path parts in info.get() 2021-05-20 18:10:51 +02:00
D. Berge
86d3323869 Remove logging statement 2021-05-20 18:10:27 +02:00
D. Berge
b181e4f424 Let the user set the search path to no survey.
This is so that we can access tables in the `public`
schema which are overloaded by survey tables, as is
the case with `info`.
2021-05-20 18:08:03 +02:00
D. Berge
7917eeeb0b Add table info to schema.
This one is independent of any projects so it goes
into `public`.
2021-05-20 18:07:05 +02:00
D. Berge
b18907fb05 Merge branch '53-mark-points-as-not-to-be-acquired-ntba' into 'devel'
Resolve "Mark points as ‘not to be acquired’ (NTBA)"

Closes #53

See merge request wgp/dougal/software!9
2021-05-17 18:34:46 +00:00
D. Berge
3e1861fcf6 Update API description 2021-05-17 20:30:59 +02:00
D. Berge
820b0c2b91 Add set line complete / incomplete actions.
The following options are shown:

* Set line complete:

If a line has been partially shot and still has points
to be acquired.

This option marks remaining virgin points as NTBA=true.

* Set line incomplete:

If a line has been partially shot and remaining virgin
points have been marked as NTBA.

This option marks all points in the line as NTBA=false.

* Set line NTBA:

If a line has not been (successfully) shot at all, i.e.,
all points on the line are virgin.

This option marks the line itself as NTBA=true.

* Unset line NTBA:

If a line has been marked as NTBA.

This option clears the NTBA flag from the line.
2021-05-17 20:19:53 +02:00
D. Berge
57f4834da8 Add information about virgin and remaining points 2021-05-17 20:19:16 +02:00
D. Berge
08d33e293a React also on preplot point changes, not just lines 2021-05-17 20:18:33 +02:00
D. Berge
8e71b18225 Add complete to line PATCH options.
`complete` is a boolean.

If true, any virgin points remaining on the line
will be marked as `ntba=true`.

If false, *all* points on the line will be marked
as `ntba=false`.
2021-05-17 20:15:34 +02:00
D. Berge
f297458954 Report on virgin points and points to be acquired.
Virgin points are those that have not been acquired
(and processed) at least once.

Points to be acquired are virgin points that do not
have the `ntba` flag set.
2021-05-17 20:13:53 +02:00
D. Berge
eb28648e57 Remove bogus dependency 2021-05-17 17:18:35 +02:00
D. Berge
0c352512b0 Enable the ‘view on map’ log action item. 2021-05-17 17:14:58 +02:00
D. Berge
4d87506720 Show a map marker if position given in URL hash.
If the location URL contains a hash of either:

* #z/x/y
* #x/y

In the first case it will zoom and pan to the location;
in the second case it will only pan while maintaining the
current (or last used) zoom level.

If the location URL does not contain a hash in one of those
formats, the marker will be removed from the map.
2021-05-17 17:14:35 +02:00
D. Berge
20bce40dac Upgrade Vue components 2021-05-17 14:22:26 +02:00
D. Berge
cf79cf86ae Fix ‘this is undefined’ error 2021-05-16 21:38:31 +02:00
D. Berge
8e4f62e5be Reset snack message when hiding.
This is so that the same message will cause the snack
to be shown again.
2021-05-16 19:58:36 +02:00
D. Berge
a8850e5d0c Protect the /project/:project/meta route 2021-05-16 19:58:03 +02:00
D. Berge
b5a762b5e3 Merge branch '108-remove-edit-controls-for-read-only-users' into 'devel'
Resolve "Remove edit controls for read-only users"

Closes #108

See merge request wgp/dougal/software!8
2021-05-16 17:56:35 +00:00
D. Berge
418f1a00b8 Hide edit controls from ready-only users 2021-05-16 19:55:31 +02:00
D. Berge
0d9f7ac4ec Add privilege level getters to Vuex.
* writeaccess: true if user can change data.
* adminaccess: true if user is an administrator.
2021-05-16 19:53:24 +02:00
D. Berge
76c9c3ef2a Assign (some) offline navdata to a survey.
There is no concept of ‘current survey’ in Dougal, and
assigning navigation data to a particular survey is full
of edge cases but sometimes it is necessary or at least
convenient to do so.

This commit implements once such strategy, which consists
of checking the distance to the preplots of all active
surveys (well, those that do have preplots anyway) and
picking the nearest one.

To reduce load, we only do this every once in a while as
governed by the `offline_survey_detect_interval` option
in the configuration.

This strategy is only active if the configuration option
`offline_survey_heuristics == "nearest_preplot"` for the
corresponding navigation header.
2021-05-16 03:16:19 +02:00
D. Berge
ef798860cd Add collect filter to template renderer.
This filter can collect attributes from items having the
same key into a single item.

Can be used in templates like this:

{% for Entry in Sequence.Entries |
   collect("ShotPointId", ["EntryType", "Comment"]) %}

to avoid duplicating shotpoint numbers.
2021-05-15 20:07:02 +02:00
D. Berge
e57c362d94 Fix error with timestamp filter (again) 2021-05-15 20:06:36 +02:00
D. Berge
7605b11fdb Fix error with timestamp Nunjucks filter 2021-05-15 18:59:47 +02:00
D. Berge
84e791fc66 Add more sequence information to SeisJSON file 2021-05-15 18:37:32 +02:00
D. Berge
3e2126cc32 Add option to download reports from sequence list.
The context menu includes options to download the sequence
report in different formats.
2021-05-15 17:12:41 +02:00
D. Berge
b0f4559b83 Allow direct downloading of sequence reports.
If the `download` or `d` query parameter is supplied (even
without any value), the response will include a
`Content-Disposition: attachment` header. A filename will
also be suggested.
2021-05-15 17:10:28 +02:00
D. Berge
c7e2e18cc8 Merge branch '84-produce-human-readable-versions-of-json-structured-sequence-data-exports-sse' into 'devel'
Resolve "Produce human-readable versions of JSON structured sequence data exports (SSE)"

Closes #84

See merge request wgp/dougal/software!7
2021-05-15 13:07:07 +00:00
D. Berge
42697fe91d Provide a default replacement for @POS@ markers 2021-05-15 01:57:46 +02:00
D. Berge
900d7f7a3e Ensure that a geometry exists 2021-05-15 01:57:46 +02:00
D. Berge
f1953807db Add position filters to Vue.
Given some text and an item containing a Point geometry,
the `position` filter replaces occurences of @POS@ or
@POSITION@ with the item's geometry (it has to be lat/lon).

Occurrences of @DMS@ are replaced with the position in
sexagesimal degrees.

This can be used anywhere a Vue filter can. However, we
have used it in the event comments edit dialogue. The positions
are replaced before saving the comment to the database.
2021-05-15 01:57:46 +02:00
D. Berge
814e071698 Add Markdown support to map tooltips 2021-05-15 01:57:46 +02:00
D. Berge
2aba132220 Add Markdown support to preplot lines comments 2021-05-15 01:57:46 +02:00
D. Berge
15a802227d Add Markdown support to planned lines comments 2021-05-15 01:57:46 +02:00
D. Berge
6745757712 Add Markdown support to log comments 2021-05-15 01:57:45 +02:00
D. Berge
9ff76867c9 Add Markdown support to sequence list comments 2021-05-15 01:57:45 +02:00
D. Berge
e8811560de Add global .markdown class.
It changes textareas to be monospaced.
2021-05-15 01:57:45 +02:00
D. Berge
65b33a6b0f Add Vue Markdown filters.
{{ '**strong** _em_' |markdown }} gives:
<p><strong>strong</strong> <em>em</em></p>

{{ '**strong** _em_' |markdownInline }} gives:
<strong>strong</strong> <em>em</em>
2021-05-15 01:57:45 +02:00
D. Berge
b8b5765b46 Split markdown Nunjucks filter into two new ones.
{{ '**strong** _em_' |markdown }} gives:
<p><strong>strong</strong> <em>em</em></p>

{{ '**strong** _em_' |markdownInline }} gives:
<strong>strong</strong> <em>em</em>
2021-05-15 01:57:45 +02:00
D. Berge
53f4e167f8 Update ‘marked’ version on server 2021-05-15 01:57:45 +02:00
D. Berge
3d8f524d4a Expose PDF output option in user interface 2021-05-15 01:57:45 +02:00
D. Berge
1e68676ac6 Add PDF output option for events log 2021-05-15 01:57:45 +02:00
D. Berge
2c2d594877 Add Selenium webdriver to backend.
Used for generating PDFs via a Firefox instance.
2021-05-15 01:57:45 +02:00
D. Berge
fae849aeab Send specific error message if HTML template not found 2021-05-15 01:57:45 +02:00
D. Berge
1d47495799 Adapt log view controls to small viewports 2021-05-15 01:57:45 +02:00
D. Berge
592632d669 Add timestamp filter to renderer 2021-05-15 01:57:45 +02:00
D. Berge
26c05b9e3c Add Markdown support to template renderer 2021-05-15 01:57:45 +02:00
D. Berge
3f9a40724d Add download menu to sequence logs.
The menu lets the user retrieve a sequence's events
in a variety of formats:

* JSON
* Seis+JSON
* GeoJSON
* HTML
2021-05-15 01:57:45 +02:00
D. Berge
a652a08815 Add GET endpoint for sequence events.
Provides a variety of formats:

* JSON
* Seis+JSON
* GeoJSON
* HTML
2021-05-15 01:57:45 +02:00
D. Berge
61ffd1b766 Refactor the function producing Seis+JSON into its own file.
For reuse.
2021-05-15 01:57:45 +02:00
D. Berge
d9f4583224 Implement GET middleware for events.
Produces a choice of outputs: JSON, GeoJSON, Seis+JSON and HTML.
2021-05-15 01:57:45 +02:00
D. Berge
95647337aa Add Nunjucks renderer.
The render function takes a JSON file and a Nunjucks
template and outputs a rendered version of the JSON
data according to the template.
2021-05-15 01:57:45 +02:00
D. Berge
b1e152179e Add new command: insert_event.py
Used to insert a timed event in the log.
2021-05-15 01:56:49 +02:00
D. Berge
142a820ed7 Process comment markers server-side.
Replace @POS@, @POSITION@ and @DMS@ in the remarks
with the event's position (sexagesimal degrees for
the last one).
2021-05-15 01:54:07 +02:00
D. Berge
838b45ef26 Do not fail if some data files are missing 2021-05-15 01:51:55 +02:00
D. Berge
30914b267a Set the right Content-Type for error outputs 2021-05-13 21:48:46 +02:00
D. Berge
f1cbbdb56b Check if raw P1/11 has records.
Even if it hasn't, the file gets imported anyway
(into the `files` table) but we exit early to avoid
an error when trying to determine shooting direction.

This check is not necessary for final P1/11 or gun data.

Fixes #104.
2021-05-12 20:35:51 +02:00
D. Berge
9973e8f132 Merge branch '94-let-users-assign-a-colour-to-preplot-lines' into 'devel'
Resolve "Let users assign a colour to preplot lines"

Closes #94

See merge request wgp/dougal/software!6
2021-05-09 22:41:51 +00:00
D. Berge
f53c479262 Add option to assign colours to preplot lines.
A ‘Set colour…’ option is available from the context menu;
it presents a dialogue allowing the user to choose a colour
that will be assigned to that preplot line and used as the
background colour for the corresponding row on the table
(may also be used for other things).

Because there is a good chance that the user may decide to
colour a large number of lines and it is cumbersome to do
it one at a time, a multiple selection option has also been
added. The context menu then shows options which will apply
to all selected rows. At this time only the change colour
option is available, but it can be extended easily.
2021-05-10 00:22:57 +02:00
D. Berge
73a415a038 Return preplot metadata via the API 2021-05-10 00:21:56 +02:00
D. Berge
0b24e3224f Let calendar toolbar follow theme.
Fixes #80.
2021-05-09 21:23:34 +02:00
D. Berge
c271256015 Remember map view settings.
Save the layer and overlay selection + map zoom and
position per user per project in the browser's
localStorage.

Closes #96.
2021-05-09 15:29:17 +02:00
D. Berge
4887ddaa26 Do not update page location on map change.
Fixes #77.
See also #96.
2021-05-09 03:52:25 +02:00
D. Berge
788c582f98 Show planned sequences in status field of preplots table.
Closes #86.
2021-05-09 00:20:02 +02:00
D. Berge
df9f7f33cf Retrieve user data for LineList.
Fixes a bug in commit fd2e0399f8.
2021-05-09 00:16:08 +02:00
D. Berge
fd2e0399f8 Remember last applied number of table rows.
A hopefully sensible default is applied, but if the
user changes it, the last selected value is saved
in the browsers localStorage.

Preferences are saved per user, project and table. And
per browser, of course, as those are only saved locally.

Closes #41.
2021-05-08 21:54:55 +02:00
D. Berge
db733ceef8 Add key to feed items 2021-05-08 21:54:06 +02:00
D. Berge
f905eb3fdf Upgrade Vuetify to latest version 2021-05-08 21:53:31 +02:00
D. Berge
e707887702 Change colour of planned lines on map.
As magenta is already used for the real-time track.
2021-05-08 20:36:19 +02:00
D. Berge
c0ace1fe07 Make check mark green if non-leaf QC item has no children.
If a test passes for all items, show the (single) check mark
and colour it green.

Leaf nodes always have their check mark in the default colour.

Related to #90.
2021-05-08 04:08:37 +02:00
D. Berge
7bb3a3910b Show development activity log.
A button in the help dialogue takes the user to the
/feed/… frontend URL, where the latest development
activity is shown, taken from the GitLab RSS feed
for the project.
2021-05-08 00:46:31 +02:00
D. Berge
983113b6cc Add flag to api action to fetch non-JSON data.
If {text:true} or another truthy value is passed as the
`text` option, the api action will use Response.text()
instead of Response.json().
2021-05-08 00:44:05 +02:00
D. Berge
ff66c9a88d Handle planner sequence value for first line in prospect.
The next sequence to shoot is normally retrieved from the
database via getSequence(), but it returns false if no
sequences have been shot yet.

In that case we use a default value of `1` to build the
name of the planned line.

Fixes #81
Fixes #82
2021-05-08 00:20:15 +02:00
D. Berge
56d30d48c5 Adapt help dialogue to small viewports 2021-05-07 23:52:36 +02:00
D. Berge
df3a0b4c50 Be explicit about what type of data is being QC'ed.
The source deviation QCs now tell the user whether raw
or final data is being QC'ed.
2021-05-07 21:29:39 +02:00
D. Berge
f87aa08246 Check if gun data missing for entire line.
The `sequences` object now carries the attribute
`has_smsrc_data`, a boolean which is true iff
there is at least one `smsrc` record in the raw
shots metadata.

This is used by:

1. A new sequence-wise test which reports if gun
   data is missing for the entire sequence.

2. The individual `missing_gun_data` test which
   is inhibited if `has_smsrc_data` for the
   corresponding sequence is false.

Closes #93.
2021-05-07 14:04:48 +02:00
D. Berge
ea499a645b Update package dependencies 2021-05-07 14:04:12 +02:00
D. Berge
0fdb42c593 Do not import files that have just been modified.
We now check that a file is at least a few seconds old
before attempting to import it.

The actual minimum age can be configured in etc/config.yaml or
else is defaults to 10 seconds.

The idea is that this should give the OS enough time to fully
write the file before we import it.

The timestamp being looked at is the modification time.

Fixes #92.
2021-05-07 13:50:32 +02:00
D. Berge
6e5584a433 Make the QC double-tick green if all items accepted.
Closes #90.
2021-05-07 13:38:26 +02:00
D. Berge
0a4df0793d Update package dependencies 2021-05-07 13:37:44 +02:00
D. Berge
1e6cc67b05 Merge branch '63-serve-api-specification' into 'devel'
Resolve "Serve API specification"

Closes #63

See merge request wgp/dougal/software!5
2020-12-30 08:45:55 +00:00
D. Berge
3c4a558e02 Serve OpenAPI document on API root.
When a client makes a request for `/` (the root of
the API), the OpenAPI description is served in an
appropriate format according to the `Accept` request
header, as follows:

Accept: text/html => HTML version
Accept: application/json => JSON version
Accept: * => YAML version
2020-12-29 16:20:57 +01:00
D. Berge
76001cffe1 Create HTML version of OpenAPI doc on install.
When running `npm install`, a self-contained HTML document
with the contents of the OpenAPI specification is saved as
openapi.html in the same directory as openapi.yaml.
2020-12-29 16:18:53 +01:00
D. Berge
45a9c5aa07 Document login and logout endpoints 2020-10-23 17:28:41 +02:00
D. Berge
f926184471 Add label descriptions to API spec 2020-10-23 15:14:52 +02:00
D. Berge
5ffd3712cf Merge branch '61-user-authentication' into devel 2020-10-23 15:14:09 +02:00
D. Berge
80451796e1 Convert expiry time to milliseconds for set-cookie 2020-10-23 14:59:45 +02:00
D. Berge
141d5805ae Reissue user login tokens when close to expiring 2020-10-23 14:50:35 +02:00
D. Berge
250ffe243d Fix JWT token time to live.
Now half an hour.
2020-10-23 14:49:52 +02:00
D. Berge
b4decd018a Add API documentation 2020-10-23 11:09:08 +02:00
D. Berge
46d489c91f Fix metadata retrieval from preplots 2020-10-23 11:01:38 +02:00
D. Berge
8a0bcc5cb4 Change HTTP response status from 201 to 204 2020-10-23 11:00:56 +02:00
D. Berge
77258b12e9 Merge branch '62-service-desk-from-ss-om-magseisfairfield-com-bug-report' into 'devel'
Resolve "Service Desk (from ss.om@magseisfairfield.com): Bug report"

Closes #62

See merge request wgp/dougal/software!4
2020-10-15 17:20:30 +00:00
D. Berge
6896d8bc87 Change sequence renumbering behaviour.
By default, change just the number of the sequence
being edited. It is checked for conflict with other
planned sequences but not with anything already acquired.

If the user ticks the ‘shift all’ checkbox, then all
planned sequences are shifted by the same amount.
2020-10-15 19:07:27 +02:00
D. Berge
80b463fbb7 Change default sequence assignment for planned lines.
If there are other lines in the planner, we increment the
highest numbered sequence in the planner by one.

If there are no planned lines, we take the highest numbered
raw sequence and increment by one.
2020-10-15 19:05:14 +02:00
D. Berge
59aaacbeee Apply access restrictions to writable routes 2020-10-12 19:43:07 +02:00
D. Berge
3c86981dc6 Add authorisation middleware.
Defines three levels of access:
* read: anyone who is logged in
* write: `user` and `admin` roles
* admin: `admin` roles
2020-10-12 19:42:02 +02:00
D. Berge
5594b6863c Do not run authentication if headers already sent 2020-10-12 19:41:00 +02:00
D. Berge
7201c29df5 Inject auth middleware after login routes.
Routes not requiring authentication must,
self-evidently, go before the authentication
middleware.
2020-10-11 22:11:36 +02:00
D. Berge
947736e8c1 Check code rather than errno.
Different versions of that library work
differently.
2020-10-11 22:10:21 +02:00
D. Berge
d782a30e90 Avoid decoding empty cookies 2020-10-11 19:59:28 +02:00
D. Berge
987dbb7700 Handle null/invalid cookies 2020-10-11 19:36:11 +02:00
D. Berge
cdd007ce88 Fix authentification middleware 2020-10-11 19:08:36 +02:00
D. Berge
a38066ec82 Set cookie / user to null if failing to decode JWT 2020-10-11 19:06:57 +02:00
D. Berge
2aca34e488 Read user login info from discrete file.
`$DOUGAL_ROOT/etc/users.yaml` to be exact.
2020-10-11 18:21:19 +02:00
D. Berge
324306a77d Remove logging statement 2020-10-11 18:20:41 +02:00
D. Berge
ab8a66bdcf Set JWT default options 2020-10-11 17:58:41 +02:00
D. Berge
b3f393a6f1 Make navigation bar user control functional.
Shows whether the user is logged in and presents
appropriate options according to whether this is
a manual or automatic login (a manual login is
when the user explicitly logs in with a user name
and password).
2020-10-11 17:57:00 +02:00
D. Berge
1ee886db63 Add login/logout views to frontend 2020-10-11 17:56:32 +02:00
D. Berge
fc9450434c Read credentials from cookie store when loading app 2020-10-11 17:55:17 +02:00
D. Berge
00f4fcf292 Read credentials from API responses 2020-10-11 17:54:34 +02:00
D. Berge
0512ac2c3c Add user module to Vuex store 2020-10-11 17:53:39 +02:00
D. Berge
dd32982cbe Add login/logout middleware 2020-10-11 17:52:13 +02:00
D. Berge
a3bfb73937 Add authentication middleware.
The user is authenticated by one of the following
methods, in order of priority:

* The presence of a valid JWT.
* Its IP.
* Its hostname.

In the case of the latter two methods, if authentication
is successful a JWT valid for 15 minutes will be generated
and passed back to the user in a cookie.
2020-10-11 13:11:43 +02:00
D. Berge
e0cd52f21a Replace favicon 2020-10-11 12:17:40 +02:00
D. Berge
d902806c32 Replace logo 2020-10-11 12:08:00 +02:00
160 changed files with 33208 additions and 1487 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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))

View File

@@ -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)

View 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:

View File

@@ -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))

View File

@@ -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
View 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()

View File

@@ -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 ]

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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
--

View File

@@ -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');
--

View 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');

View 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
--

View 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
--

View 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
--

View 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
--

View 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
--

View 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
--

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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)"

View File

@@ -1,6 +1,9 @@
{
"jwt": {
"secret": ""
"secret": "",
"options": {
"expiresIn": 1800
}
},
"db": {
"user": "postgres",

File diff suppressed because it is too large Load Diff

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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()
}
};

View 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 (01)",
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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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"))
};
}

View File

@@ -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()

View File

@@ -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: {

View 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)"}}
}
};

View 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 };

View File

@@ -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
};

View File

@@ -0,0 +1,4 @@
export default function unpack(rows, key) {
return rows && rows.map( row => row[key] );
};

View File

@@ -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λ
}

View File

@@ -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) => {

View File

@@ -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
}
]

View File

@@ -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

View File

@@ -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) {

View 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
};

View 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 };

View File

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

View File

@@ -0,0 +1,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 };

View File

@@ -0,0 +1,7 @@
const state = () => ({
cookie: null,
user: null,
preferences: {}
});
export default state;

View File

@@ -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 }">

View 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>

View 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>

View 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>

View File

@@ -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];
}
}

View File

@@ -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();

View 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>

View 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>

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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;
}
},

View File

@@ -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
```

View File

@@ -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') {

View 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
};

View 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;

View File

@@ -1,3 +1,4 @@
exports.jwt = require('./jwt');
// exports.access = require('./access');
exports.authentify = require('./authentify');
exports.access = require('./access');

View 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;

View 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;

View 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);
}
}

View 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;

View 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;

View 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;

View File

@@ -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 {

View File

@@ -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')
};

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -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);

View 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);
}
};

View File

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

View File

@@ -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);

View 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;

View 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;

View File

@@ -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);

View 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;

View 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);
}
}

View File

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

View 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);
}
};

View 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);
}
}

View 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);
}
};

View File

@@ -0,0 +1,3 @@
exports.login = require('./login');
exports.logout = require('./logout');

View File

@@ -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;

View File

@@ -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