Compare commits

...

261 Commits

Author SHA1 Message Date
D. Berge
c96ada6b78 Merge branch '334-add-4d-comparisons' into 'devel'
Resolve "Add 4D comparisons"

Closes #334

See merge request wgp/dougal/software!60
2025-08-22 15:02:07 +00:00
D. Berge
673c60a359 Add error handling 2025-08-22 16:40:06 +02:00
D. Berge
99e425270c getGroup() returns all comparisons.
Just like saveGroup() saves all.
2025-08-22 16:39:22 +02:00
D. Berge
63633715e2 Guard against underpopulated groups 2025-08-22 16:16:10 +02:00
D. Berge
8afac5c150 Fix indentation 2025-08-22 16:01:20 +02:00
D. Berge
11168def68 Fix typos 2025-08-22 16:01:20 +02:00
D. Berge
0f477b8e65 Replace tilt icons 2025-08-22 16:01:20 +02:00
D. Berge
03b00a4ea7 Remove dead code 2025-08-22 16:01:20 +02:00
D. Berge
c5faa53bee Add more view controls to group map 2025-08-22 16:01:20 +02:00
D. Berge
46b2512530 Add more view controls to map 2025-08-22 16:01:20 +02:00
D. Berge
db4c9a0235 Add script to update comparison groups.
This should be run at regular intervals (via cron or so) to keep
the comparisons up to date.

It is not necessarily a good idea to run this as part of the
runner.sh script as it will delay other tasks trying to
update the active project every time.

Probably OK to put it on a cronjbo every 2‒24 hours. If two
copies are running concurrently that should not break anything
but it will increase the server load.
2025-08-22 16:01:20 +02:00
D. Berge
1a12ea13ed Return project timestamps 2025-08-22 16:01:20 +02:00
D. Berge
81717c37f1 Add option to return project timestamp 2025-08-22 16:01:20 +02:00
D. Berge
6377e8854c Updated wanted db schema 2025-08-22 16:01:20 +02:00
D. Berge
d3446d03bd Add database upgrade file 44 2025-08-22 16:01:20 +02:00
D. Berge
a52f7811f2 Clean up dead code 2025-08-22 16:01:20 +02:00
D. Berge
ef2bd4888e Update the required schema version.
This is necessary for the comparisons code to work.
2025-08-22 16:01:20 +02:00
D. Berge
8801442c92 Don't show monitor lines by default 2025-08-22 16:01:20 +02:00
D. Berge
30f65dbeaa Make loading indicator spin when 0% 2025-08-22 16:01:20 +02:00
D. Berge
c2f53ac150 Remove unneded dependency 2025-08-22 16:01:20 +02:00
D. Berge
4328fc4d2a Fix typo 2025-08-22 16:01:20 +02:00
D. Berge
2c2eb8fceb Add group map view 2025-08-22 16:01:20 +02:00
D. Berge
767c2f2cb1 Add support for type 4 decoding 2025-08-22 16:01:20 +02:00
D. Berge
57a73f7d1c Fix component paths 2025-08-22 16:01:20 +02:00
D. Berge
9f299056d8 Move components to subdirectory 2025-08-22 16:01:20 +02:00
D. Berge
5d3c59867c Return type 4 sequence data 2025-08-22 16:01:20 +02:00
D. Berge
76b8355ede Add encoding type 4 to bundle 2025-08-22 16:01:20 +02:00
D. Berge
76b55f514d Link from group summary to individual projects 2025-08-22 16:01:20 +02:00
D. Berge
4e1d3209df Don't request summaries in ProjectList.
Those will be populated directly by Vuex.
2025-08-22 16:01:20 +02:00
D. Berge
f21ff7ee38 Try to improve responsiveness when refreshing project list 2025-08-22 16:01:20 +02:00
D. Berge
2446b42785 Expand groups router definition 2025-08-22 16:01:20 +02:00
D. Berge
196e772004 Make event handler more specific 2025-08-22 16:01:20 +02:00
D. Berge
674d818fee Rework comparison components.
More focused on error ellipses.
2025-08-22 16:01:20 +02:00
D. Berge
5527576679 Refresh comparisons when notified of changes 2025-08-22 16:01:20 +02:00
D. Berge
fe7c016dea Add control to reset comparisons view 2025-08-22 16:01:20 +02:00
D. Berge
b7543aa6c4 Add overlays when loading / data error 2025-08-22 16:01:20 +02:00
D. Berge
b48a060dc0 Don't cache comparisons in the API 2025-08-22 16:01:20 +02:00
D. Berge
c0f9a2de5a Don't save comparison samples 2025-08-22 16:01:20 +02:00
D. Berge
32a9c7a5f2 Add comparisons channel to notifications 2025-08-22 16:01:20 +02:00
D. Berge
f1f74080f6 Add database upgrade file 43 2025-08-22 16:01:20 +02:00
D. Berge
c5eb8e45f1 Add database upgrade file 42 2025-08-22 16:01:20 +02:00
D. Berge
caab968fd6 Add database upgrade file 41 2025-08-22 16:01:20 +02:00
D. Berge
5f28d1be7b Don't overwrite existing comparisons unless forced.
opts.overwrite = true will cause existing comparisons to be
recomputed.
2025-08-22 16:01:20 +02:00
D. Berge
22c9537889 Fix non-existent method 2025-08-22 16:01:20 +02:00
D. Berge
e95aaa7de7 Add link to group comparison from project list 2025-08-22 16:01:20 +02:00
D. Berge
4f44f5a10c Add frontend route for 4D comparisons 2025-08-22 16:01:20 +02:00
D. Berge
0ba467d34c Add 4D comparisons list Vue component 2025-08-22 16:01:20 +02:00
D. Berge
2b5b302e54 Add 4D comparisons Vue component 2025-08-22 16:01:20 +02:00
D. Berge
28938e27a9 Add utilities for transforming duration objects 2025-08-22 16:01:20 +02:00
D. Berge
97f96fdc1e Add Vue components for 4D comparisons 2025-08-22 16:01:20 +02:00
D. Berge
1e3ce35f76 Add set operations utilities 2025-08-22 16:01:20 +02:00
D. Berge
619a886781 Add comparison API endpoints 2025-08-22 16:01:20 +02:00
D. Berge
c054e63325 Add two new bundle types.
Of which 0xa is not actually used and 0xc is used for geometric
comparison data ([ line, point, δi, δj ]).
2025-08-22 16:01:20 +02:00
D. Berge
fd94b3b6f4 Add comparison functions to server/lib 2025-08-22 16:01:20 +02:00
D. Berge
7b67b4afc9 Fix bug trying to get project info for undefined 2025-08-22 16:01:20 +02:00
D. Berge
7c52ada922 Add project group info to Vuex 2025-08-22 16:01:20 +02:00
D. Berge
9072bbe389 Add iterators 2025-08-22 16:01:20 +02:00
D. Berge
6639b7110b Add sequence navigation controls to log.
Closes #135
2025-08-22 15:57:49 +02:00
D. Berge
be6652b539 Name Shotlog route 2025-08-22 15:56:59 +02:00
D. Berge
bf054d3902 Persist event log user preferences 2025-08-22 15:56:12 +02:00
D. Berge
2734870871 Fix errors when loading graphs.
Errors due to the parent element having zero width / height or
rendering too early.
2025-08-22 15:54:17 +02:00
D. Berge
52f49e6799 Fix log entries pagination.
Fixes #340
2025-08-22 12:31:19 +02:00
D. Berge
30150a8728 Kill runner if it hangs around for too long.
This gives the import processes a chance to run.
2025-08-21 15:33:05 +02:00
D. Berge
ef8466992c Add automatic event icon to log.
So that the user can visually see which events were created by
Dougal (not including QC events).
2025-08-18 11:22:58 +02:00
D. Berge
8e4e70cbdc Add server status info to help dialogue 2025-08-17 13:19:51 +02:00
D. Berge
4dadffbbe7 Refactor Selenium to make it more robust.
It should stop runaway Firefox processes.
2025-08-17 13:18:04 +02:00
D. Berge
24dcebd0d9 Remove logging statements 2025-08-17 13:17:22 +02:00
D. Berge
12a762f44f Fix typo in @dougal/binary 2025-08-16 14:55:53 +02:00
D. Berge
ebf13abc28 Merge branch '337-fix-event-queue' into 'devel'
Resolve "Automatic event detection fault: soft start on every shot during line"

Closes #337

See merge request wgp/dougal/software!61
2025-08-16 12:55:15 +00:00
D. Berge
b3552db02f Add error checking to ETag logic 2025-08-16 11:36:43 +02:00
D. Berge
cd882c0611 Add debug info to soft start detection 2025-08-16 11:36:43 +02:00
D. Berge
6fc9c020a4 Fix off-by-one error in LGSP detection 2025-08-16 11:36:43 +02:00
D. Berge
75284322f1 Modify full volume detection on Smartsource
The Smartsource firmware seems to have changed rendering the old
test invalid.
2025-08-16 11:36:43 +02:00
D. Berge
e849c47f01 Remove old queue implementation 2025-08-16 11:36:43 +02:00
D. Berge
387d20a4f0 Rewrite automatic event handling system 2025-08-16 11:36:43 +02:00
D. Berge
2fab06d340 Don't send timestamp when patching seq+point events.
Closes #339.
2025-08-16 11:35:35 +02:00
D. Berge
7d2fb5558a Hide switches to enable additional graphs.
All violin plots as well as position scatter plots and histograms
are shown by default. This is due to #338.

For some reason, having them enabled from the get go does not
cause any problems.
2025-08-15 18:09:51 +02:00
D. Berge
764e2cfb23 Rename endpoint 2025-08-14 13:34:36 +02:00
D. Berge
bf1af1f76c Make it explicit that :id is numeric 2025-08-14 13:34:27 +02:00
D. Berge
09e4cd2467 Add CSV event import.
Closes #336
2025-08-14 13:33:30 +02:00
D. Berge
2009d73a2b Fix action registration and unregistration 2025-08-13 17:03:00 +02:00
D. Berge
083ee812de Use cookies for authentication as a last resort.
Fixes #335
2025-08-13 16:54:38 +02:00
D. Berge
84510e8dc9 Add proper logging 2025-08-13 15:42:49 +02:00
D. Berge
7205ec42a8 Fix handler registration.
The way it was being done meant that unregisterHandlers would not
have worked.
2025-08-13 15:42:49 +02:00
D. Berge
73d85ef81f Fix scheduling of token refresh via websocket 2025-08-13 12:58:36 +02:00
D. Berge
6c4dc35461 Fix bad status on preplot lines tab
If there were no raw / final sequences on a line, planned sequences
would not show either.
2025-08-13 12:45:50 +02:00
D. Berge
a5ebff077d Fix authentication middleware erroring on IPv6 2025-08-13 11:50:20 +02:00
D. Berge
2a894692ce Throttle snack notifications 2025-08-12 00:22:09 +02:00
D. Berge
25690eeb52 Fix showSnack in main.js 2025-08-11 23:48:08 +02:00
D. Berge
3f9776b61d Let the user know when we're getting gateway errors 2025-08-11 23:47:25 +02:00
D. Berge
8c81daefc0 Move the two /configuration endpoints next to each other 2025-08-11 22:20:46 +02:00
D. Berge
c173610e87 Simplify middleware 2025-08-11 22:19:51 +02:00
D. Berge
301e5c0731 Set headers only on 304 2025-08-11 22:06:51 +02:00
D. Berge
48d9f45fe0 Clean up debug messages 2025-08-11 22:06:20 +02:00
D. Berge
cd23a78592 Merge branch '190-refactor-map' into 'devel'
Resolve "Refactor map"

Closes #190, #322, #323, #324, #325, #326, and #321

See merge request wgp/dougal/software!25
2025-08-11 13:01:00 +00:00
D. Berge
e368183bf0 Show release notes for previous versions too 2025-08-11 14:59:22 +02:00
D. Berge
02477b071b Compress across the board.
It's still subject to the compression module's filters, but now
we try to compress every response in principle.
2025-08-11 13:57:11 +02:00
D. Berge
6651868ea7 Enable compression for vessel track responses 2025-08-11 13:40:53 +02:00
D. Berge
c0b52a8245 Be more aggressive about what gets compressed 2025-08-11 12:42:48 +02:00
D. Berge
90ce6f063e Remove dead code 2025-08-11 02:31:43 +02:00
D. Berge
b2fa0c3d40 Flatten vesselTrackConfig for better reactivity 2025-08-11 02:31:12 +02:00
D. Berge
83ecaad4fa Change vessel colour 2025-08-11 01:57:40 +02:00
D. Berge
1c5fd2e34d Calculate properly first / last timestamps of vessel tracks 2025-08-11 01:56:46 +02:00
D. Berge
aabcc74891 Add compression to some endpoints.
Consideration will be given to adding (conditional) compression
to all endpoints.
2025-08-11 01:53:50 +02:00
D. Berge
2a7b51b995 Squash another cookie 2025-08-11 01:52:04 +02:00
D. Berge
5d19ca7ca7 Add authentication to vessel track request 2025-08-10 22:03:25 +02:00
D. Berge
910195fc0f Comment out "Map settings" control on map.
Not sure it will actually be used, after all.
2025-08-10 21:53:55 +02:00
D. Berge
6e5570aa7c Add missing require 2025-08-10 21:53:04 +02:00
D. Berge
595c20f504 Add vessel position to map.
Updates via websocket using the `realtime` channel notification
message.
2025-08-10 21:52:02 +02:00
D. Berge
40d0038d80 Add vessel track layer to map.
Track length may be changed by clicking on the appropriate icon.
2025-08-10 21:47:43 +02:00
D. Berge
acdf118a67 Add new /vessel/track endpoints.
This is a variation on /navdata but returns data more suitable
for plotting vessel tracks on the map.
2025-08-10 21:39:35 +02:00
D. Berge
b9e0975d3d Add clone routine to project DB lib (WIP).
This relates to #333.
2025-08-10 21:37:12 +02:00
D. Berge
39d9c9d748 Fix GeoJSON returned by /navdata endpoint 2025-08-10 21:36:37 +02:00
D. Berge
b8b25dcd62 Update IP getter script to return LAN address.
get-ip.sh internet: returns the first IP address found that has
internet access.

get-ip.sh local (or no argument): returns the list of non-loopback
IPs minus the one that has internet access.

This means that update-dns.sh now sends the first IP address that
does *not* have internet access.
2025-08-09 22:27:23 +02:00
D. Berge
db97382758 Add scripts to automatically update the LAN DNS records.
./sbin/update-dns.sh may be called at regular intervals (one hour
or so) via crontab.

It will automatically detect:
- its local host name (*.lan.dougal.aaltronav.eu); and
- which IP has internet access, if any.

Armed with that information and with the dynamic DNS API password
stored in DYNDNS_PASSWD in ~/.dougalrc, it will update the relevant
DNS record.

For this to work, the first `lan.dougal` hostname in the Nginx
configuration must be the one that is set up for dynamic update.
Other `lan.dougal` hostnames should be CNAME records pointing to
the first one.
2025-08-09 18:37:15 +02:00
D. Berge
ae8e5d4ef6 Do not use cookies for backend authentication 2025-08-09 12:43:17 +02:00
D. Berge
2c1a24e4a5 Do not store JWT in document.cookie 2025-08-09 12:14:17 +02:00
D. Berge
0b83187372 Provide authorisation details to Deck.gl layers.
Those layers that call API endpoints directly no longer need to
rely on cookies as they use the JWT token directly via the
`Authorization` header.
2025-08-09 12:12:24 +02:00
D. Berge
3dd51c82ea Adapt map links to new format 2025-08-08 18:54:25 +02:00
D. Berge
17e6564e70 Implement map crosshairs.
These are coordinates that are supplied in the fragment part of the
URL. When available, a marker is shown at the given positions.
Labels may also be given and are also shown.
2025-08-08 18:51:54 +02:00
D. Berge
3a769e7fd0 Adapt to new map implementation.
Note: if we implement a fallback to the old Leaflet code, the new
hash format will need to be accepted in Leaflet too.
2025-08-08 16:10:17 +02:00
D. Berge
7dde0a15c6 Fix handling of view state and layers in URL hash 2025-08-08 16:09:32 +02:00
D. Berge
2872af8d60 Refresh sequence line data on every render 2025-08-08 13:48:49 +02:00
D. Berge
4e581d5664 Add final-raw heatmap 2025-08-08 13:47:30 +02:00
D. Berge
a188e9a099 Tweak colour scales 2025-08-08 13:45:54 +02:00
D. Berge
cd6ad92d5c Use the same names in the user interface as in the code 2025-08-08 13:44:42 +02:00
D. Berge
08dfe7ef0a Add notification handlers to Map.
They reload any sequence data on notification of changes.
2025-08-08 12:45:15 +02:00
D. Berge
6a5238496e Add possibility to refresh points map while loading binary data 2025-08-08 12:44:21 +02:00
D. Berge
bc237cb685 Add final data points layer to map 2025-08-08 12:43:27 +02:00
D. Berge
4957142fb1 Refactor sequenceBinaryData.
It is no longer a computed property but actual data. It gets
recalculated on demand via getSequenceBinaryData().
2025-08-08 12:42:38 +02:00
D. Berge
5a19c81ed1 Unregister notification handlers.
When leaving the Project component, all its notification handlers
will be unregistered, otherwise we end up with a memory leak.
2025-08-08 12:22:56 +02:00
D. Berge
b583dc6c02 Support unregistering notification handlers 2025-08-08 12:20:58 +02:00
D. Berge
134e3bce4e Add client-side support for type 3 bundles (final data) 2025-08-08 12:20:04 +02:00
D. Berge
f5ad9d7182 Use sequenceBinaryData for raw data points layer.
Saves us from ending up with an extra copy of the data.
2025-08-08 12:18:07 +02:00
D. Berge
07874ffe0b Filter missing values out of JSON data for binary packing 2025-08-08 12:15:39 +02:00
D. Berge
695add5da6 Increase the resolution of position errors in bundle.
Note: this does not actually matter as of this commit as we are
storing those values as Float32 but it will become relevant when
we start packing them as Int16.
2025-08-08 12:15:05 +02:00
D. Berge
6a94287cba Add type 3 binary bundle.
Consisting of final positions + errors.
2025-08-08 11:24:16 +02:00
D. Berge
c2ec2970f0 Remove dead code 2025-08-08 11:20:03 +02:00
D. Berge
95d6d0054b Adapt GIS endpoint to new preplots tables structure 2025-08-07 22:02:04 +02:00
D. Berge
5070be5ff3 Handle event changes 2025-08-07 20:18:18 +02:00
D. Berge
d5e77bc946 Move API action option to the correct argument 2025-08-07 19:20:27 +02:00
D. Berge
f6faad17db Fix Python's idiotic syntax 2025-08-07 17:17:43 +02:00
D. Berge
94cdf83b13 Change access permissions to files endpoints 2025-08-07 16:23:55 +02:00
D. Berge
6a788ae28b Add logging statements 2025-08-07 16:23:14 +02:00
D. Berge
544117eec3 Fix retrieval of preplot previews 2025-08-07 16:20:00 +02:00
D. Berge
e5679ec14b Move API action option to the correct argument 2025-08-07 16:19:13 +02:00
D. Berge
a1c174994c Remove debugging statements 2025-08-07 13:03:43 +02:00
D. Berge
2db8cc3116 Tweak wording 2025-08-07 12:38:04 +02:00
D. Berge
99b1a841c5 Let the user know when using a remote frontend.
Note: this relies on the gateway Nginx server configurations
including an X-Dougal-Server header, as follows:

add_header X-Dougal-Server "remote-frontend" always;
2025-08-07 12:30:28 +02:00
D. Berge
6629e25644 Do not error if version history is undefined 2025-08-07 11:03:07 +02:00
D. Berge
7f5f64acb1 Check for lineNameInfo when importing P1/11 2025-08-07 11:00:42 +02:00
D. Berge
8f87df1e2f Comment out debug output 2025-08-07 10:52:13 +02:00
D. Berge
8399782409 Set response auth headers conditionally 2025-08-07 10:42:37 +02:00
D. Berge
9c86018653 Auto-refresh materialised view if necessary 2025-08-07 10:42:08 +02:00
D. Berge
a15c97078b Fix typo in access middleware 2025-08-07 10:41:29 +02:00
D. Berge
d769ec48dd Request fresh responses when refreshing data from the server 2025-08-07 10:40:23 +02:00
D. Berge
fe421f545c Add data integrity check 2025-08-06 22:54:01 +02:00
D. Berge
caa8fec8cc Log warning 2025-08-06 22:52:06 +02:00
D. Berge
49fc260ace Clear cookie on logout 2025-08-06 22:51:44 +02:00
D. Berge
b7038f542c Fix storage of JWT in localStorage 2025-08-06 22:51:20 +02:00
D. Berge
40ad0e7650 Fix database upgrades 38, 39, 40.
Ensure the changes are applied to the public schema.
2025-08-06 22:50:20 +02:00
D. Berge
9006deb8be Change error notifications 2025-08-06 12:01:03 +02:00
D. Berge
6e19b8e18f Do not fail if old / new missing from notifications.
The server will actually remove those when the notification
would exceed a certain size, so it's expected that those might
be null.
2025-08-06 11:59:52 +02:00
D. Berge
3d474ad8f8 Update package-lock.json 2025-08-06 11:31:51 +02:00
D. Berge
821af18f29 Removed planned line points layer control.
Not necessary as we already have the preplots layer
2025-08-06 11:25:44 +02:00
D. Berge
9cf15ce9dd Edit code comments 2025-08-06 11:24:39 +02:00
D. Berge
78838cbc41 Implement planned lines layer 2025-08-06 11:20:40 +02:00
D. Berge
8855da743b Handle refresh on data change for some layers.
Binary layers not included yet.
2025-08-06 11:17:37 +02:00
D. Berge
c67a60a7e6 Fix labels handling in events map layer 2025-08-06 11:14:20 +02:00
D. Berge
81e06930f0 Silence console error 2025-08-06 11:05:15 +02:00
D. Berge
0263eab6d1 Add extra mutations to plan Vuex module.
They're not actually needed though. 🙄
2025-08-06 11:03:11 +02:00
D. Berge
931219850e Fix wrong freezing of Vuex data.
It's the sequence items themselves that benefit from freezing,
not the sequence array itself.
2025-08-06 11:01:57 +02:00
D. Berge
12369d5419 Support Markdown-formatted snack messages 2025-08-06 11:01:10 +02:00
D. Berge
447003c3b5 Implement pub-sub handler system for ws notifications. 2025-08-06 10:59:17 +02:00
D. Berge
be7157b62c Downgrade gracefully if window.caches is not available.
This should not happen in production, as the Cache API is
widely implemented as of the date of this commit, but it
will not be available if the user is not in a secure
context. That should only happen during testing.
2025-08-06 10:45:05 +02:00
D. Berge
8ef56f9946 Pass a clone of Response to API callback 2025-08-06 10:42:34 +02:00
D. Berge
f2df16fe55 Fix getting project configuration data 2025-08-06 10:41:42 +02:00
D. Berge
96db6b1376 Add a more helpful message if cause of failure is known 2025-08-06 10:41:08 +02:00
D. Berge
36d86c176a Only send websocket notifications to authenticated users 2025-08-06 10:40:16 +02:00
D. Berge
9c38af4bc0 Improve handling of JWT over websocket.
When a valid `token` message is received from a client, the
socket server will automatically push refreshed tokens at
about half lifetime of the received JWT.

If an invalid token is received the connection is closed.

See #304.
2025-08-06 10:26:53 +02:00
D. Berge
be5c6f1fa3 Fix user authentication.
* Use X-JWT header for sending authentication info
  both from server to client and from client to server.
* Send token in body of login response.
* Also use Set-Cookie: JWT=… so that calls that are
  not issued directly by Dougal (e.g. Deck.gl layers
  with a URL `data` property) work without having to
  jump through hoops.

Closes #321
2025-08-06 10:21:37 +02:00
D. Berge
17b9d60715 Make sourceLayer optional in getPickingInfo 2025-08-04 18:47:15 +02:00
D. Berge
e2dd563054 Save changed to package-lock.json 2025-08-03 13:50:59 +02:00
D. Berge
67dcc2922b Fix binary bundling of delta spread 2025-08-03 13:49:55 +02:00
D. Berge
11e84f47eb Fix refresh to remove only data for current project 2025-08-03 13:48:51 +02:00
D. Berge
1066a03b25 Leave layer menu open when still focused 2025-08-03 13:48:04 +02:00
D. Berge
08440e3e21 Add tooltip to heatmap control 2025-08-03 13:47:48 +02:00
D. Berge
d46eb3b455 Add gun misfire options to menu 2025-08-03 13:47:07 +02:00
D. Berge
864b430320 Fix no fire / autofire values (they're not boolean!) 2025-08-03 13:45:56 +02:00
D. Berge
61cbefd0e9 Tweak heatmap parameters 2025-08-03 13:45:31 +02:00
D. Berge
29c484affa Add misfire options to heatmap 2025-08-03 13:45:07 +02:00
D. Berge
0806b80445 Remove dead code 2025-08-03 13:43:53 +02:00
D. Berge
b5a3a22892 Add full screen control to map 2025-08-03 11:57:59 +02:00
D. Berge
c13aa23e2f Add heatmaps to map (various data facets) 2025-08-03 11:57:12 +02:00
D. Berge
3366377ab0 Use preplot point layers on map 2025-08-03 11:56:05 +02:00
D. Berge
59a90e352c Add tooltips for preplot layers 2025-08-03 11:53:55 +02:00
D. Berge
0f207f8c2d Add heatmap layer 2025-08-03 11:53:24 +02:00
D. Berge
c97eaa64f5 Add preplot point layers (sail / source line) 2025-08-03 11:52:48 +02:00
D. Berge
5b82f8540d Use DougalBinaryLoader for sequence points layers 2025-08-03 11:51:47 +02:00
D. Berge
d977d9c40b Add support for udv values 0 and 1 to DougalSequenceLayer.
udv = 0 → sail line points
udv = 1 → source line points
2025-08-03 11:44:42 +02:00
D. Berge
d16fb41f24 Add DougalBinaryLoader Deck.gl loader 2025-08-03 11:39:03 +02:00
D. Berge
c376896ea6 Also serve preplot source/sail points as binary.
This commit adds the ability to pack preplot points in Dougal
binary format. Sail line points take udv=0 and source line points
take udv=1 – udv=2 remains sequence data.

Endpoints for retrieving the data in JSON, GeoJSON and binary
formats have also been added. Data may be retrieved as a single
line or for a whole project.
2025-08-03 11:17:31 +02:00
D. Berge
2bcdee03d5 Further refactor Map component.
Map.sequencesBinaryData is now a single object instead of an
array of objects.

DougalSequenceLayer has been greatly simplified. It now
inherits from ScatterplotLayer rather than CompositeLayer.

DougalEventsLayer added. It shows either a ScatteplotLayer
or a ColumnsLayer depending on zoom level.
2025-08-02 16:00:54 +02:00
D. Berge
44113c89c0 Further refactor Map component.
Layer and tooltip definitions have been split out into different
files as mixins.

Uses Dougal binary bundles.
2025-08-01 17:18:16 +02:00
D. Berge
17c6d9d1e5 Add DougalSequenceLayer 2025-08-01 17:16:36 +02:00
D. Berge
06cc16721f Remove SequenceDataLayer 2025-08-01 17:15:27 +02:00
D. Berge
af7485370c Limit number of simultaneous requests to the API 2025-08-01 17:11:34 +02:00
D. Berge
ad013ea642 Add additional formats for sequence list endpoint.
The original and default "Accept: application/json" will return
a sequence summary.

"Accept: application/geo+json" will return a GeoJSON of the
entire project.

"Accept: application/vnd.aaltronav.dougal+octet-stream" will
return the entire project in Dougal's binary format.
2025-08-01 17:07:37 +02:00
D. Berge
48d5986415 Change handling of sequence parameter.
Allow `null` to be used in addition to `0` in
db.sequence.get() to return all sequences.
2025-08-01 17:05:38 +02:00
D. Berge
471f4e8e64 Add synonyms to db.sequence.get() options 2025-08-01 17:05:05 +02:00
D. Berge
4be99370e6 Change the MIME type of binary responses 2025-08-01 16:50:32 +02:00
D. Berge
e464f5f887 Refactor code handling binary sequence requests.
Instead of the user giving the recipe for the payload, it now
only handles predefined payload configurations. Those are
denoted by the `type` query parameter. The only valid value
as of this commit is `type=2`.

Look at lib/binary/bundle.js for the definition of a type 2
bundle.
2025-08-01 16:47:50 +02:00
D. Berge
cc8d790ad8 Remove dead code (replaced by @dougal/binary) 2025-08-01 16:43:22 +02:00
D. Berge
32c6e2c79f Add @dougal/concurrency module 2025-08-01 11:22:30 +02:00
D. Berge
ba7221ae10 Implement getData*() functions in DougalBinaryBundle 2025-07-30 17:41:17 +02:00
D. Berge
1cb9d4b1e2 Add @dougal/binary module.
It encodes / decodes sequence / preplot data using an efficient
binary format for sending large amounts of data across the wire
and for (relatively) memory efficient client-side use.
2025-07-30 17:37:00 +02:00
D. Berge
2a0025cdbf Try to fix FSP / LSP times for the third time 2025-07-29 13:31:17 +02:00
D. Berge
f768f31b62 Aesthetic changes to map layers control 2025-07-28 12:09:02 +02:00
D. Berge
9f91b1317f Add map settings control (mock up).
This is not yet implemented but left visible for demo purposes.

Intended to configure things such as vessel track length, possibly
whether the latest track or the track within the current prospect
is shown, etc.
2025-07-28 12:06:56 +02:00
D. Berge
3b69a15703 Add manual refresh control to map.
It may or may not be permanenet, once tasks #322, #323, #324, #325
are implemented.

Closes #326
2025-07-28 12:05:10 +02:00
D. Berge
cd3bd8ab79 Fix FSP/LSP times (again) 2025-07-28 12:04:27 +02:00
D. Berge
df193a99cd Add sleep() method to main.js.
Useful when the UI needs to "pause" for UX reasons. Can be called
from any component with `this.$root.sleep(ms)`.
2025-07-28 12:02:49 +02:00
D. Berge
580e94a591 Await on binary data download requests 2025-07-28 11:09:55 +02:00
D. Berge
3413641c10 Fix first and last shotpoint times in map tooltip 2025-07-28 11:01:38 +02:00
D. Berge
f092aff015 Fix navdata URL 2025-07-28 11:01:08 +02:00
D. Berge
94c6406ea2 Add missing dependency 2025-07-28 10:37:58 +02:00
D. Berge
244d84a3bd Add more layers to Map component.
This commits adds back the vessel track as well as other layers,
gives the option to load both point and line versions of the plan,
raw, and final sequences, and adds heatmaps showing positioning
error of raw and final data relative to preplots.

The implementation in this commit relies on translating the binary
sequence data into JSON (for the heatmaps) which is inefficient
but adequate as an initial proof of concept.
2025-07-28 10:14:41 +02:00
D. Berge
89c565a0f5 Protect against out of bounds array condition 2025-07-28 10:10:05 +02:00
D. Berge
31ac8d3c01 Add toJSON() function to binary decoder 2025-07-28 10:07:49 +02:00
D. Berge
3bb78040b0 Set correct Content-Type 2025-07-28 10:06:21 +02:00
D. Berge
1433bda14e Make the iterator more robust against failures.
If a sequence fails to be fetched, it will keep iterating rather
than throwing an error or returning invalid data.
2025-07-27 11:16:47 +02:00
D. Berge
c0ae033de8 Use Cache API to cache binary sequence data.
This speeds up loading when the user moves away from and then
revisits the map tab.

NOTE: As of this commit, there is no way to refresh or invalidate
the cache.
2025-07-27 11:15:09 +02:00
D. Berge
05eed7ef26 Comment out Norwegian nautical charts layer.
It has apparently become inaccessible in recent times.
2025-07-27 11:07:08 +02:00
D. Berge
5d2ca513a6 Add check for WebGL support.
The intention is to fall back to the legacy map if WebGL is not
supported on a client.
2025-07-27 11:06:12 +02:00
D. Berge
b9c8069828 Add an error overlay.
Assinging to `error` on the Map componenent will cause an overlay
with an error <v-alert/> to be shown.
2025-07-27 11:03:26 +02:00
D. Berge
b80b8ffb52 Add cache option to api Vuex action.
It allows the caching and retrieval of requests using Cache API.
2025-07-27 11:01:34 +02:00
D. Berge
c2eb82ffe7 Modify view on map link 2025-07-26 19:14:29 +02:00
D. Berge
e517e2f771 Refactor map component.
Uses Deck.gl rather than Leaflet.
2025-07-26 19:13:58 +02:00
D. Berge
0afd54447f Add SequenceDataLayer Deck.gl class.
It takes the typed arrays returned by the binary-encoded
endpoints.
2025-07-26 19:06:56 +02:00
D. Berge
e6004dd62f Add link to binary library.
Same library is used server and client side.
2025-07-26 19:06:56 +02:00
D. Berge
f623954399 Adapt to new calling convention for Vuex action 2025-07-26 19:06:56 +02:00
D. Berge
f8d882da5d Replace text parameter by format in Vuex API call.
Instead of { text: true } as a Fetch option, one can
now specify { format: "text" }, as well as any of these
other options, which call the corresponding Fetch method:

* "arrayBuffer",
* "blob",
* "formData",
* "json",
* "text"
2025-07-26 19:06:56 +02:00
D. Berge
808c9987af Add binary format middleware for sequence data.
It responds to the MIME type:
application/dougal-map-sequence+octet-stream
2025-07-26 19:05:00 +02:00
D. Berge
4db6d8dd7a Add custom binary format packing / unpacking.
This series of custom binary messages are an alternative to JSON /
GeoJSON when huge amounts of data needs to be transferred to and
processed by the client, such as a GPU-based map view showing all
the points for a prospect, or QC graphs, etc.
2025-07-26 19:05:00 +02:00
D. Berge
9a47977f5f Improve help dialogue.
- Shows frontend and backend versions
- Shows version release notes
2025-07-26 10:59:40 +02:00
D. Berge
a58cce8565 Add /version/history endpoint to API.
Retrieves Git tag annotations.
2025-07-26 10:58:42 +02:00
D. Berge
5487a3a49b Catch JWT expiration.
Closes #321
2025-07-26 10:56:23 +02:00
D. Berge
731778206c Show front and backend version on help dialogue 2025-07-25 23:15:07 +02:00
D. Berge
08e65b512d Inject frontend version as environment variable 2025-07-25 23:14:30 +02:00
D. Berge
9b05388113 Add database upgrade file 40 2025-07-25 21:17:20 +02:00
D. Berge
1b44389a1a Allow configuring the API URL via environment variable.
The environment variable DOUGAL_API_URL takes precedence
over the hard-coded value. For instance:

DOUGAL_API_URL=http://127.0.0.1:2999 will cause /api to
be proxied to the above URL (websockets are correctly
handled too) instead of the default.
2025-07-25 20:08:38 +02:00
D. Berge
0b3711b759 Fix typo 2025-07-25 20:08:08 +02:00
D. Berge
5a523d4941 Make projects table sorted by default 2025-07-25 20:07:40 +02:00
D. Berge
122951e3a2 Fix expected DB version for upgrade 38 2025-07-25 18:11:19 +02:00
D. Berge
90216c12e4 Rename database upgrades 2025-07-25 18:08:47 +02:00
D. Berge
9c26909a59 Fix npm run scripts 2025-07-25 17:54:56 +02:00
D. Berge
0427a3c18c Use Node workspaces to manage repo dependencies 2025-07-25 17:48:30 +02:00
D. Berge
c32e6f2b38 Make map API calls silent.
Otherwise we get spurious 404s and such.
2025-07-25 17:17:36 +02:00
D. Berge
546d199c52 Remove annooying Leaflet attribution control 2025-07-25 17:17:36 +02:00
D. Berge
6562de97b9 Make the CSS import from package not relative 2025-07-25 17:17:36 +02:00
D. Berge
c666a6368e Fix copy/paste logic for lineNameInfo widget 2025-07-25 14:41:21 +02:00
168 changed files with 436696 additions and 32713 deletions

View File

@@ -23,6 +23,7 @@ transform = {
}
def parse_line (line, fields, fixed = None):
# print("parse_line", line, fields, fixed)
data = dict()
if fixed:
@@ -51,6 +52,7 @@ def parse_line (line, fields, fixed = None):
data[key] = value
# print("parse_line data =", data)
return data

View File

@@ -73,6 +73,12 @@ if __name__ == '__main__':
lineNameInfo = final_p111.get("lineNameInfo")
pattern = final_p111.get("pattern")
if not lineNameInfo:
if not pattern:
print("ERROR! Missing final.p111.lineNameInfo in project configuration. Cannot import final P111")
raise Exception("Missing final.p111.lineNameInfo")
else:
print("WARNING! No `lineNameInfo` in project configuration (final.p111). You should add it to the settings.")
rx = None
if pattern and pattern.get("regex"):
rx = re.compile(pattern["regex"])

View File

@@ -41,6 +41,12 @@ if __name__ == '__main__':
lineNameInfo = raw_p111.get("lineNameInfo")
pattern = raw_p111.get("pattern")
if not lineNameInfo:
if not pattern:
print("ERROR! Missing raw.p111.lineNameInfo in project configuration. Cannot import raw P111")
raise Exception("Missing raw.p111.lineNameInfo")
else:
print("WARNING! No `lineNameInfo` in project configuration (raw.p111). You should add it to the settings.")
rx = None
if pattern and pattern.get("regex"):
rx = re.compile(pattern["regex"])
@@ -104,6 +110,7 @@ if __name__ == '__main__':
file_info["meta"] = {
"fileInfo": parsed_line
}
p111_data = p111.from_file(physical_filepath)
print("Saving")

View File

@@ -1,5 +1,7 @@
#!/bin/bash
# Maximum runtime in seconds before killing an overdue instance (e.g., 10 minutes)
MAX_RUNTIME_SECONDS=$((15 * 60))
DOUGAL_ROOT=${DOUGAL_ROOT:-$(dirname "$0")/..}
@@ -80,8 +82,9 @@ function run () {
# DESCRIPTION=""
SERVICE="deferred_imports"
$BINDIR/send_alert.py -t "$TITLE" -s "$SERVICE" -l "critical" \
-O "$(cat $STDOUTLOG)" -E "$(cat $STDERRLOG)"
# Disable GitLab alerts. They're just not very practical
# $BINDIR/send_alert.py -t "$TITLE" -s "$SERVICE" -l "critical" \
# -O "$(cat $STDOUTLOG)" -E "$(cat $STDERRLOG)"
exit 2
}
@@ -98,12 +101,35 @@ function cleanup () {
if [[ -f $LOCKFILE ]]; then
PID=$(cat "$LOCKFILE")
if pgrep -F "$LOCKFILE"; then
print_warning $(printf "The previous process is still running (%d)" $PID)
if kill -0 "$PID" 2>/dev/null; then # Check if process is running
# Get elapsed time in D-HH:MM:SS format and convert to seconds
ELAPSED_STR=$(ps -p "$PID" -o etime= | tr -d '[:space:]')
if [ -n "$ELAPSED_STR" ]; then
# Convert D-HH:MM:SS to seconds
ELAPSED_SECONDS=$(echo "$ELAPSED_STR" | awk -F'[-:]' '{
seconds = 0
if (NF == 4) { seconds += $1 * 86400 } # Days
if (NF >= 3) { seconds += $NF-2 * 3600 } # Hours
if (NF >= 2) { seconds += $NF-1 * 60 } # Minutes
seconds += $NF # Seconds
print seconds
}')
if [ "$ELAPSED_SECONDS" -gt "$MAX_RUNTIME_SECONDS" ]; then
# Kill the overdue process (SIGTERM; use -9 for SIGKILL if needed)
kill "$PID" 2>/dev/null
print_warning $(printf "Killed overdue process (%d) that ran for %s (%d seconds)" "$PID" "$ELAPSED_STR" "$ELAPSED_SECONDS")
rm "$LOCKFILE"
else
print_warning $(printf "Previous process is still running (%d) for %s (%d seconds)" "$PID" "$ELAPSED_STR" "$ELAPSED_SECONDS")
exit 1
fi
else
print_warning $(printf "Could not retrieve elapsed time for process (%d)" "$PID")
exit 1
fi
else
rm "$LOCKFILE"
print_warning $(printf "Previous process (%d) not found. Must have died unexpectedly" $PID)
print_warning $(printf "Previous process (%d) not found. Must have died unexpectedly" "$PID")
fi
fi

89
bin/update_comparisons.js Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/node
const cmp = require('../lib/www/server/lib/comparisons');
async function purgeComparisons () {
const groups = await cmp.groups();
const comparisons = await cmp.getGroup();
const pids = new Set(Object.values(groups).flat().map( p => p.pid ));
const comparison_pids = new Set(comparisons.map( c => [ c.baseline_pid, c.monitor_pid ] ).flat());
for (const pid of comparison_pids) {
if (!pids.has(pid)) {
console.log(`${pid} no longer par of a group. Deleting comparisons`);
staleComps = comparisons.filter( c => c.baseline_pid == pid || c.monitor_pid == pid );
for (c of staleComps) {
console.log(`Deleting comparison ${c.baseline_pid}${c.monitor_pid}`);
await cmp.remove(c.baseline_pid, c.monitor_pid);
}
}
}
}
async function main () {
console.log("Looking for unreferenced comparisons to purge");
await purgeComparisons();
console.log("Retrieving project groups");
const groups = await cmp.groups();
if (!Object.keys(groups??{})?.length) {
console.log("No groups found");
return 0;
}
console.log(`Found ${Object.keys(groups)?.length} groups: ${Object.keys(groups).join(", ")}`);
for (const groupName of Object.keys(groups)) {
const projects = groups[groupName];
console.log(`Fetching saved comparisons for ${groupName}`);
const comparisons = await cmp.getGroup(groupName);
if (!comparisons || !comparisons.length) {
console.log(`No comparisons found for ${groupName}`);
continue;
}
// Check if there are any projects that have been modified since last comparison
// or if there are any pairs that are no longer part of the group
const outdated = comparisons.filter( c => {
const baseline_tstamp = projects.find( p => p.pid === c.baseline_pid )?.tstamp;
const monitor_tstamp = projects.find( p => p.pid === c.monitor_pid )?.tstamp;
return (c.tstamp < baseline_tstamp) || (c.tstamp < monitor_tstamp) ||
baseline_tstamp == null || monitor_tstamp == null;
});
for (const comparison of outdated) {
console.log(`Removing stale comparison: ${comparison.baseline_pid}${comparison.monitor_pid}`);
await cmp.remove(comparison.baseline_pid, comparison.monitor_pid);
}
if (projects?.length < 2) {
console.log(`Group ${groupName} has less than two projects. No comparisons are possible`);
continue;
}
// Re-run the comparisons that are not in the database. They may
// be missing either beacause they were not there to start with
// or because we just removed them due to being stale
console.log(`Recalculating group ${groupName}`);
await cmp.saveGroup(groupName);
}
console.log("Comparisons update done");
return 0;
}
if (require.main === module) {
main();
} else {
module.exports = main;
}

View File

@@ -38,6 +38,7 @@ CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', 'public';
SET search_path TO public;
CREATE TABLE IF NOT EXISTS keystore (
type TEXT NOT NULL, -- A class of data to be stored
@@ -79,7 +80,7 @@ BEGIN
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.5.3' THEN
IF current_db_version != '0.5.4' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';

View File

@@ -41,6 +41,7 @@ CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', 'public';
SET search_path TO public;
INSERT INTO keystore (type, key, data)
VALUES ('user', '6f1e7159-4ca0-4ae4-ab4e-89078166cc10', '

View File

@@ -0,0 +1,106 @@
-- Fix final_lines_summary view
--
-- New schema version: 0.6.2
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade only affects the `public` schema.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This update adds an "organisations" section to the configuration,
-- with a default configured organisation of "WGP" with full access.
-- This is so that projects can be made accessible after migrating
-- to the new permissions architecture.
--
-- In addition, projects with an id starting with "eq" are assumed to
-- be Equinor projects, and an additional organisation is added with
-- read-only access. This is intended for clients, which should be
-- assigned to the "Equinor organisation".
--
-- Finally, we assign the vessel to the "WGP" organisation (full access)
-- so that we can actually use administrative endpoints.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', 'public';
SET search_path TO public;
-- Add "organisations" section to configurations, if not already present
UPDATE projects
SET
meta = jsonb_set(meta, '{organisations}', '{"WGP": {"read": true, "write": true, "edit": true}}'::jsonb, true)
WHERE meta->'organisations' IS NULL;
-- Add (or overwrite!) "organisations.Equinor" giving read-only access (can be changed later via API)
UPDATE projects
SET
meta = jsonb_set(meta, '{organisations, Equinor}', '{"read": true, "write": false, "edit": false}'::jsonb, true)
WHERE pid LIKE 'eq%';
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.6.2' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.6.1' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
CALL pg_temp.upgrade_database();
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_database ();
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.6.2"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.6.2"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -0,0 +1,109 @@
-- Add procedure to decimate old nav data
--
-- New schema version: 0.6.3
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade creates a new schema called `comparisons`.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This update adds a `comparisons` table to a `comparisons` schema.
-- The `comparisons.comparisons` table holds 4D prospect comparison data.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', 'public';
SET search_path TO public;
-- BEGIN
CREATE SCHEMA IF NOT EXISTS comparisons
AUTHORIZATION postgres;
COMMENT ON SCHEMA comparisons
IS 'Holds 4D comparison data and logic';
CREATE TABLE IF NOT EXISTS comparisons.comparisons
(
type text COLLATE pg_catalog."default" NOT NULL,
baseline_pid text COLLATE pg_catalog."default" NOT NULL,
monitor_pid text COLLATE pg_catalog."default" NOT NULL,
data bytea,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT comparisons_pkey PRIMARY KEY (baseline_pid, monitor_pid, type)
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS comparisons.comparisons
OWNER to postgres;
-- END
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.6.3' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.6.2' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
CALL pg_temp.upgrade_database();
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_database ();
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.6.3"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.6.3"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -0,0 +1,169 @@
-- Add procedure to decimate old nav data
--
-- New schema version: 0.6.4
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects the public schema only.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This update modifies notify() to accept, as optional arguments, the
-- names of columns that are to be *excluded* from the notification.
-- It is intended for tables with large columns which are however of
-- no particular interest in a notification.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', 'public';
SET search_path TO public;
-- BEGIN
CREATE OR REPLACE FUNCTION public.notify()
RETURNS trigger
LANGUAGE 'plpgsql'
COST 100
VOLATILE NOT LEAKPROOF
AS $BODY$
DECLARE
channel text := TG_ARGV[0];
pid text;
payload text;
notification text;
payload_id integer;
old_json jsonb;
new_json jsonb;
excluded_col text;
i integer;
BEGIN
-- Fetch pid
SELECT projects.pid INTO pid FROM projects WHERE schema = TG_TABLE_SCHEMA;
-- Build old and new as jsonb, excluding specified columns if provided
IF OLD IS NOT NULL THEN
old_json := row_to_json(OLD)::jsonb;
FOR i IN 1 .. TG_NARGS - 1 LOOP
excluded_col := TG_ARGV[i];
old_json := old_json - excluded_col;
END LOOP;
ELSE
old_json := NULL;
END IF;
IF NEW IS NOT NULL THEN
new_json := row_to_json(NEW)::jsonb;
FOR i IN 1 .. TG_NARGS - 1 LOOP
excluded_col := TG_ARGV[i];
new_json := new_json - excluded_col;
END LOOP;
ELSE
new_json := NULL;
END IF;
-- Build payload
payload := json_build_object(
'tstamp', CURRENT_TIMESTAMP,
'operation', TG_OP,
'schema', TG_TABLE_SCHEMA,
'table', TG_TABLE_NAME,
'old', old_json,
'new', new_json,
'pid', pid
)::text;
-- Handle large payloads
IF octet_length(payload) < 1000 THEN
PERFORM pg_notify(channel, payload);
ELSE
-- Store large payload and notify with ID (as before)
INSERT INTO notify_payloads (payload) VALUES (payload) RETURNING id INTO payload_id;
notification := json_build_object(
'tstamp', CURRENT_TIMESTAMP,
'operation', TG_OP,
'schema', TG_TABLE_SCHEMA,
'table', TG_TABLE_NAME,
'pid', pid,
'payload_id', payload_id
)::text;
PERFORM pg_notify(channel, notification);
RAISE INFO 'Payload over limit';
END IF;
RETURN NULL;
END;
$BODY$;
ALTER FUNCTION public.notify()
OWNER TO postgres;
-- END
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.6.4' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.6.3' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
CALL pg_temp.upgrade_database();
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_database ();
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.6.4"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.6.4"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -0,0 +1,96 @@
-- Add procedure to decimate old nav data
--
-- New schema version: 0.6.5
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects the public schema only.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This update modifies notify() to accept, as optional arguments, the
-- names of columns that are to be *excluded* from the notification.
-- It is intended for tables with large columns which are however of
-- no particular interest in a notification.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', 'public';
SET search_path TO public;
-- BEGIN
CREATE OR REPLACE TRIGGER comparisons_tg
AFTER INSERT OR DELETE OR UPDATE
ON comparisons.comparisons
FOR EACH ROW
EXECUTE FUNCTION public.notify('comparisons', 'data');
-- END
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.6.5' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.6.4' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
CALL pg_temp.upgrade_database();
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_database ();
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.6.5"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.6.5"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -0,0 +1,157 @@
-- Add procedure to decimate old nav data
--
-- New schema version: 0.6.6
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects the public schema only.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This adds a last_project_update(pid) function. It takes a project ID
-- and returns the last known timestamp from that project. Timestamps
-- are derived from multiple sources:
--
-- - raw_shots table
-- - final_shots table
-- - events_log_full table
-- - info table where key = 'qc'
-- - files table, from the hashes (which contain the file's mtime)
-- - project configuration, looking for an _updatedOn property
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can be applied multiple times without ill effect.
--
BEGIN;
CREATE OR REPLACE PROCEDURE pg_temp.show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade_database () AS $outer$
BEGIN
RAISE NOTICE 'Updating schema %', 'public';
SET search_path TO public;
-- BEGIN
CREATE OR REPLACE FUNCTION public.last_project_update(p_pid text)
RETURNS timestamp with time zone
LANGUAGE plpgsql
AS $function$
DECLARE
v_last_ts timestamptz := NULL;
v_current_ts timestamptz;
v_current_str text;
v_current_unix numeric;
v_sid_rec record;
BEGIN
-- From raw_shots, final_shots, info, and files
FOR v_sid_rec IN SELECT schema FROM public.projects WHERE pid = p_pid
LOOP
-- From raw_shots
EXECUTE 'SELECT max(tstamp) FROM ' || v_sid_rec.schema || '.raw_shots' INTO v_current_ts;
IF v_current_ts > v_last_ts OR v_last_ts IS NULL THEN
v_last_ts := v_current_ts;
END IF;
-- From final_shots
EXECUTE 'SELECT max(tstamp) FROM ' || v_sid_rec.schema || '.final_shots' INTO v_current_ts;
IF v_current_ts > v_last_ts OR v_last_ts IS NULL THEN
v_last_ts := v_current_ts;
END IF;
-- From info where key = 'qc'
EXECUTE 'SELECT value->>''updatedOn'' FROM ' || v_sid_rec.schema || '.info WHERE key = ''qc''' INTO v_current_str;
IF v_current_str IS NOT NULL THEN
v_current_ts := v_current_str::timestamptz;
IF v_current_ts > v_last_ts OR v_last_ts IS NULL THEN
v_last_ts := v_current_ts;
END IF;
END IF;
-- From files hash second part, only for valid colon-separated hashes
EXECUTE 'SELECT max( split_part(hash, '':'', 2)::numeric ) FROM ' || v_sid_rec.schema || '.files WHERE hash ~ ''^[0-9]+:[0-9]+\\.[0-9]+:[0-9]+\\.[0-9]+:[0-9a-f]+$''' INTO v_current_unix;
IF v_current_unix IS NOT NULL THEN
v_current_ts := to_timestamp(v_current_unix);
IF v_current_ts > v_last_ts OR v_last_ts IS NULL THEN
v_last_ts := v_current_ts;
END IF;
END IF;
-- From event_log_full
EXECUTE 'SELECT max(tstamp) FROM ' || v_sid_rec.schema || '.event_log_full' INTO v_current_ts;
IF v_current_ts > v_last_ts OR v_last_ts IS NULL THEN
v_last_ts := v_current_ts;
END IF;
END LOOP;
-- From projects.meta->_updatedOn
SELECT (meta->>'_updatedOn')::timestamptz FROM public.projects WHERE pid = p_pid INTO v_current_ts;
IF v_current_ts > v_last_ts OR v_last_ts IS NULL THEN
v_last_ts := v_current_ts;
END IF;
RETURN v_last_ts;
END;
$function$;
-- END
END;
$outer$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE pg_temp.upgrade () AS $outer$
DECLARE
row RECORD;
current_db_version TEXT;
BEGIN
SELECT value->>'db_schema' INTO current_db_version FROM public.info WHERE key = 'version';
IF current_db_version >= '0.6.6' THEN
RAISE EXCEPTION
USING MESSAGE='Patch already applied';
END IF;
IF current_db_version != '0.6.5' THEN
RAISE EXCEPTION
USING MESSAGE='Invalid database version: ' || current_db_version,
HINT='Ensure all previous patches have been applied.';
END IF;
CALL pg_temp.upgrade_database();
END;
$outer$ LANGUAGE plpgsql;
CALL pg_temp.upgrade();
CALL pg_temp.show_notice('Cleaning up');
DROP PROCEDURE pg_temp.upgrade_database ();
DROP PROCEDURE pg_temp.upgrade ();
CALL pg_temp.show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.6.6"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.6.6"}' WHERE public.info.key = 'version';
CALL pg_temp.show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE pg_temp.show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -0,0 +1,968 @@
const codeToType = {
0: Int8Array,
1: Uint8Array,
2: Int16Array,
3: Uint16Array,
4: Int32Array,
5: Uint32Array,
7: Float32Array,
8: Float64Array,
9: BigInt64Array,
10: BigUint64Array
};
const typeToBytes = {
Int8Array: 1,
Uint8Array: 1,
Int16Array: 2,
Uint16Array: 2,
Int32Array: 4,
Uint32Array: 4,
Float32Array: 4,
Float64Array: 8,
BigInt64Array: 8,
BigUint64Array: 8
};
function readTypedValue(view, offset, type) {
switch (type) {
case Int8Array: return view.getInt8(offset);
case Uint8Array: return view.getUint8(offset);
case Int16Array: return view.getInt16(offset, true);
case Uint16Array: return view.getUint16(offset, true);
case Int32Array: return view.getInt32(offset, true);
case Uint32Array: return view.getUint32(offset, true);
case Float32Array: return view.getFloat32(offset, true);
case Float64Array: return view.getFloat64(offset, true);
case BigInt64Array: return view.getBigInt64(offset, true);
case BigUint64Array: return view.getBigUint64(offset, true);
default: throw new Error(`Unsupported type: ${type.name}`);
}
}
function writeTypedValue(view, offset, value, type) {
switch (type) {
case Int8Array: view.setInt8(offset, value); break;
case Uint8Array: view.setUint8(offset, value); break;
case Int16Array: view.setInt16(offset, value, true); break;
case Uint16Array: view.setUint16(offset, value, true); break;
case Int32Array: view.setInt32(offset, value, true); break;
case Uint32Array: view.setUint32(offset, value, true); break;
case Float32Array: view.setFloat32(offset, value, true); break;
case Float64Array: view.setFloat64(offset, value, true); break;
case BigInt64Array: view.setBigInt64(offset, BigInt(value), true); break;
case BigUint64Array: view.setBigUint64(offset, BigInt(value), true); break;
default: throw new Error(`Unsupported type: ${type.name}`);
}
}
class DougalBinaryBundle extends ArrayBuffer {
static HEADER_LENGTH = 4; // Length of a bundle header
/** Clone an existing ByteArray into a DougalBinaryBundle
*/
static clone (buffer) {
const clone = new DougalBinaryBundle(buffer.byteLength);
const uint8Array = new Uint8Array(buffer);
const uint8ArrayClone = new Uint8Array(clone);
uint8ArrayClone.set(uint8Array);
return clone;
}
constructor (length, options) {
super (length, options);
}
/** Get the count of bundles in this ByteArray.
*
* Stops at the first non-bundle looking offset
*/
get bundleCount () {
let count = 0;
let currentBundleOffset = 0;
const view = new DataView(this);
while (currentBundleOffset < this.byteLength) {
const currentBundleHeader = view.getUint32(currentBundleOffset, true);
if ((currentBundleHeader & 0xff) !== 0x1c) {
// This is not a bundle
return count;
}
let currentBundleLength = currentBundleHeader >>> 8;
currentBundleOffset += currentBundleLength + DougalBinaryBundle.HEADER_LENGTH;
count++;
}
return count;
}
/** Get the number of chunks in the bundles of this ByteArray
*/
get chunkCount () {
let count = 0;
let bundleOffset = 0;
const view = new DataView(this);
while (bundleOffset < this.byteLength) {
const header = view.getUint32(bundleOffset, true);
if ((header & 0xFF) !== 0x1C) break;
const length = header >>> 8;
if (bundleOffset + 4 + length > this.byteLength) break;
let chunkOffset = bundleOffset + 4; // relative to buffer start
while (chunkOffset < bundleOffset + 4 + length) {
const chunkType = view.getUint8(chunkOffset);
if (chunkType !== 0x11 && chunkType !== 0x12) break;
const cCount = view.getUint16(chunkOffset + 2, true);
const ΔelemC = view.getUint8(chunkOffset + 10);
const elemC = view.getUint8(chunkOffset + 11);
let localOffset = 12; // header size
localOffset += ΔelemC + elemC; // preface
// initial values
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(chunkOffset + 12 + k);
const baseCode = typeByte & 0xF;
const baseType = codeToType[baseCode];
if (!baseType) throw new Error('Invalid base type code');
localOffset += typeToBytes[baseType.name];
}
// pad after initial
while (localOffset % 4 !== 0) localOffset++;
if (chunkType === 0x11) { // Sequential
// record data: Δelems incrs
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(chunkOffset + 12 + k);
const incrCode = typeByte >> 4;
const incrType = codeToType[incrCode];
if (!incrType) throw new Error('Invalid incr type code');
localOffset += cCount * typeToBytes[incrType.name];
}
// elems
for (let k = 0; k < elemC; k++) {
const typeCode = view.getUint8(chunkOffset + 12 + ΔelemC + k);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid elem type code');
localOffset += cCount * typeToBytes[type.name];
}
} else { // Interleaved
// Compute exact stride for interleaved record data
let ΔelemStride = 0;
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(chunkOffset + 12 + k);
const incrCode = typeByte >> 4;
const incrType = codeToType[incrCode];
if (!incrType) throw new Error('Invalid incr type code');
ΔelemStride += typeToBytes[incrType.name];
}
let elemStride = 0;
for (let k = 0; k < elemC; k++) {
const typeCode = view.getUint8(chunkOffset + 12 + ΔelemC + k);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid elem type code');
elemStride += typeToBytes[type.name];
}
const recordStride = ΔelemStride + elemStride;
localOffset += cCount * recordStride;
}
// pad after record
while (localOffset % 4 !== 0) localOffset++;
chunkOffset += localOffset;
count++;
}
bundleOffset += 4 + length;
}
return count;
}
/** Return an array of DougalBinaryChunkSequential or DougalBinaryChunkInterleaved instances
*/
chunks () {
const chunks = [];
let bundleOffset = 0;
const view = new DataView(this);
while (bundleOffset < this.byteLength) {
const header = view.getUint32(bundleOffset, true);
if ((header & 0xFF) !== 0x1C) break;
const length = header >>> 8;
if (bundleOffset + 4 + length > this.byteLength) break;
let chunkOffset = bundleOffset + 4;
while (chunkOffset < bundleOffset + 4 + length) {
const chunkType = view.getUint8(chunkOffset);
if (chunkType !== 0x11 && chunkType !== 0x12) break;
const cCount = view.getUint16(chunkOffset + 2, true);
const ΔelemC = view.getUint8(chunkOffset + 10);
const elemC = view.getUint8(chunkOffset + 11);
let localOffset = 12;
localOffset += ΔelemC + elemC;
// initial values
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(chunkOffset + 12 + k);
const baseCode = typeByte & 0xF;
const baseType = codeToType[baseCode];
if (!baseType) throw new Error('Invalid base type code');
localOffset += typeToBytes[baseType.name];
}
// pad after initial
while (localOffset % 4 !== 0) localOffset++;
if (chunkType === 0x11) { // Sequential
// record data: Δelems incrs
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(chunkOffset + 12 + k);
const incrCode = typeByte >> 4;
const incrType = codeToType[incrCode];
if (!incrType) throw new Error('Invalid incr type code');
localOffset += cCount * typeToBytes[incrType.name];
}
// elems
for (let k = 0; k < elemC; k++) {
const typeCode = view.getUint8(chunkOffset + 12 + ΔelemC + k);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid elem type code');
localOffset += cCount * typeToBytes[type.name];
}
} else { // Interleaved
// Compute exact stride for interleaved record data
let ΔelemStride = 0;
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(chunkOffset + 12 + k);
const incrCode = typeByte >> 4;
const incrType = codeToType[incrCode];
if (!incrType) throw new Error('Invalid incr type code');
ΔelemStride += typeToBytes[incrType.name];
}
let elemStride = 0;
for (let k = 0; k < elemC; k++) {
const typeCode = view.getUint8(chunkOffset + 12 + ΔelemC + k);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid elem type code');
elemStride += typeToBytes[type.name];
}
const recordStride = ΔelemStride + elemStride;
localOffset += cCount * recordStride;
}
// pad after record
while (localOffset % 4 !== 0) localOffset++;
switch (chunkType) {
case 0x11:
chunks.push(new DougalBinaryChunkSequential(this, chunkOffset, localOffset));
break;
case 0x12:
chunks.push(new DougalBinaryChunkInterleaved(this, chunkOffset, localOffset));
break;
default:
throw new Error('Invalid chunk type');
}
chunkOffset += localOffset;
}
bundleOffset += 4 + length;
}
return chunks;
}
/** Return a ByteArray containing all data from all
* chunks including reconstructed i, j and incremental
* values as follows:
*
* <i_0> <i_1> … <i_x> // i values (constant)
* <j_0> <j_1> … <j_x> // j values (j0 + Δj*i)
* <Δelem_0_0> <Δelem_0_1> … <Δelem_0_x> // reconstructed Δelem0 (uses baseType)
* <Δelem_1_0> <Δelem_1_1> … <Δelem_1_x> // reconstructed Δelem1
* …
* <Δelem_y_0> <Δelem_y_1> … <Δelem_y_x> // reconstructed Δelem1
* <elem_0_0> <elem_0_1> … <elem_0_x> // First elem
* <elem_1_0> <elem_1_1> … <elem_1_x> // Second elem
* …
* <elem_z_0> <elem_z_1> … <elem_z_x> // Last elem
*
* It does not matter whether the underlying chunks are
* sequential or interleaved. This function will transform
* as necessary.
*
*/
getDataSequentially () {
const chunks = this.chunks();
if (chunks.length === 0) return new ArrayBuffer(0);
const firstChunk = chunks[0];
const ΔelemC = firstChunk.ΔelemCount;
const elemC = firstChunk.elemCount;
// Check consistency across chunks
for (const chunk of chunks) {
if (chunk.ΔelemCount !== ΔelemC || chunk.elemCount !== elemC) {
throw new Error('Inconsistent chunk structures');
}
}
// Get types from first chunk
const view = new DataView(firstChunk);
const ΔelemBaseTypes = [];
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(12 + k);
const baseCode = typeByte & 0xF;
const baseType = codeToType[baseCode];
if (!baseType) throw new Error('Invalid base type code');
ΔelemBaseTypes.push(baseType);
}
const elemTypes = [];
for (let k = 0; k < elemC; k++) {
const typeCode = view.getUint8(12 + ΔelemC + k);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid elem type code');
elemTypes.push(type);
}
// Compute total records
const totalN = chunks.reduce((sum, c) => sum + c.jCount, 0);
// Compute sizes
const size_i = totalN * 2; // Uint16 for i
const size_j = totalN * 4; // Int32 for j
let size_Δelems = 0;
for (const t of ΔelemBaseTypes) {
size_Δelems += totalN * typeToBytes[t.name];
}
let size_elems = 0;
for (const t of elemTypes) {
size_elems += totalN * typeToBytes[t.name];
}
const totalSize = size_i + size_j + size_Δelems + size_elems;
const ab = new ArrayBuffer(totalSize);
const dv = new DataView(ab);
// Write i's
let off = 0;
for (const chunk of chunks) {
const i = chunk.i;
for (let idx = 0; idx < chunk.jCount; idx++) {
dv.setUint16(off, i, true);
off += 2;
}
}
// Write j's
off = size_i;
for (const chunk of chunks) {
const j0 = chunk.j0;
const Δj = chunk.Δj;
for (let idx = 0; idx < chunk.jCount; idx++) {
const j = j0 + idx * Δj;
dv.setInt32(off, j, true);
off += 4;
}
}
// Write Δelems
off = size_i + size_j;
for (let m = 0; m < ΔelemC; m++) {
const type = ΔelemBaseTypes[m];
const bytes = typeToBytes[type.name];
for (const chunk of chunks) {
const arr = chunk.Δelem(m);
for (let idx = 0; idx < chunk.jCount; idx++) {
writeTypedValue(dv, off, arr[idx], type);
off += bytes;
}
}
}
// Write elems
for (let m = 0; m < elemC; m++) {
const type = elemTypes[m];
const bytes = typeToBytes[type.name];
for (const chunk of chunks) {
const arr = chunk.elem(m);
for (let idx = 0; idx < chunk.jCount; idx++) {
writeTypedValue(dv, off, arr[idx], type);
off += bytes;
}
}
}
return ab;
}
/** Return a ByteArray containing all data from all
* chunks including reconstructed i, j and incremental
* values, interleaved as follows:
*
* <i_0> <j_0> <Δelem_0_0> <Δelem_1_0> … <Δelem_y_0> <elem_0_0> <elem_1_0> … <elem_z_0>
* <i_1> <j_1> <Δelem_0_1> <Δelem_1_1> … <Δelem_y_1> <elem_0_1> <elem_1_1> … <elem_z_1>
* <i_x> <j_x> <Δelem_0_x> <Δelem_1_x> … <Δelem_y_x> <elem_0_x> <elem_1_x> … <elem_z_x>
*
* It does not matter whether the underlying chunks are
* sequential or interleaved. This function will transform
* as necessary.
*
*/
getDataInterleaved () {
const chunks = this.chunks();
if (chunks.length === 0) return new ArrayBuffer(0);
const firstChunk = chunks[0];
const ΔelemC = firstChunk.ΔelemCount;
const elemC = firstChunk.elemCount;
// Check consistency across chunks
for (const chunk of chunks) {
if (chunk.ΔelemCount !== ΔelemC || chunk.elemCount !== elemC) {
throw new Error('Inconsistent chunk structures');
}
}
// Get types from first chunk
const view = new DataView(firstChunk);
const ΔelemBaseTypes = [];
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(12 + k);
const baseCode = typeByte & 0xF;
const baseType = codeToType[baseCode];
if (!baseType) throw new Error('Invalid base type code');
ΔelemBaseTypes.push(baseType);
}
const elemTypes = [];
for (let k = 0; k < elemC; k++) {
const typeCode = view.getUint8(12 + ΔelemC + k);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid elem type code');
elemTypes.push(type);
}
// Compute total records
const totalN = chunks.reduce((sum, c) => sum + c.jCount, 0);
// Compute record size
const recordSize = 2 + 4 + // i (Uint16) + j (Int32)
ΔelemBaseTypes.reduce((sum, t) => sum + typeToBytes[t.name], 0) +
elemTypes.reduce((sum, t) => sum + typeToBytes[t.name], 0);
const totalSize = totalN * recordSize;
const ab = new ArrayBuffer(totalSize);
const dv = new DataView(ab);
let off = 0;
for (const chunk of chunks) {
const i = chunk.i;
const j0 = chunk.j0;
const Δj = chunk.Δj;
for (let idx = 0; idx < chunk.jCount; idx++) {
dv.setUint16(off, i, true);
off += 2;
const j = j0 + idx * Δj;
dv.setInt32(off, j, true);
off += 4;
for (let m = 0; m < ΔelemC; m++) {
const type = ΔelemBaseTypes[m];
const bytes = typeToBytes[type.name];
const arr = chunk.Δelem(m);
writeTypedValue(dv, off, arr[idx], type);
off += bytes;
}
for (let m = 0; m < elemC; m++) {
const type = elemTypes[m];
const bytes = typeToBytes[type.name];
const arr = chunk.elem(m);
writeTypedValue(dv, off, arr[idx], type);
off += bytes;
}
}
}
return ab;
}
get records () {
const data = [];
for (const record of this) {
data.push(record.slice(1));
}
return data;
}
[Symbol.iterator]() {
const chunks = this.chunks();
let chunkIndex = 0;
let chunkIterator = chunks.length > 0 ? chunks[0][Symbol.iterator]() : null;
return {
next() {
if (!chunkIterator) {
return { done: true };
}
let result = chunkIterator.next();
while (result.done && chunkIndex < chunks.length - 1) {
chunkIndex++;
chunkIterator = chunks[chunkIndex][Symbol.iterator]();
result = chunkIterator.next();
}
return result;
}
};
}
}
class DougalBinaryChunkSequential extends ArrayBuffer {
constructor (buffer, offset, length) {
super(length);
new Uint8Array(this).set(new Uint8Array(buffer, offset, length));
this._ΔelemCaches = new Array(this.ΔelemCount);
this._elemCaches = new Array(this.elemCount);
this._ΔelemBlockOffsets = null;
this._elemBlockOffsets = null;
this._recordOffset = null;
}
_getRecordOffset() {
if (this._recordOffset !== null) return this._recordOffset;
const view = new DataView(this);
const ΔelemC = this.ΔelemCount;
const elemC = this.elemCount;
let recordOffset = 12 + ΔelemC + elemC;
for (let k = 0; k < ΔelemC; k++) {
const tb = view.getUint8(12 + k);
const bc = tb & 0xF;
const bt = codeToType[bc];
recordOffset += typeToBytes[bt.name];
}
while (recordOffset % 4 !== 0) recordOffset++;
this._recordOffset = recordOffset;
return recordOffset;
}
_initBlockOffsets() {
if (this._ΔelemBlockOffsets !== null) return;
const view = new DataView(this);
const count = this.jCount;
const ΔelemC = this.ΔelemCount;
const elemC = this.elemCount;
const recordOffset = this._getRecordOffset();
this._ΔelemBlockOffsets = [];
let o = recordOffset;
for (let k = 0; k < ΔelemC; k++) {
this._ΔelemBlockOffsets[k] = o;
const tb = view.getUint8(12 + k);
const ic = tb >> 4;
const it = codeToType[ic];
o += count * typeToBytes[it.name];
}
this._elemBlockOffsets = [];
for (let k = 0; k < elemC; k++) {
this._elemBlockOffsets[k] = o;
const tc = view.getUint8(12 + ΔelemC + k);
const t = codeToType[tc];
o += count * typeToBytes[t.name];
}
}
/** Return the user-defined value
*/
get udv () {
return new DataView(this).getUint8(1);
}
/** Return the number of j elements in this chunk
*/
get jCount () {
return new DataView(this).getUint16(2, true);
}
/** Return the i value in this chunk
*/
get i () {
return new DataView(this).getUint16(4, true);
}
/** Return the j0 value in this chunk
*/
get j0 () {
return new DataView(this).getUint16(6, true);
}
/** Return the Δj value in this chunk
*/
get Δj () {
return new DataView(this).getInt16(8, true);
}
/** Return the Δelem_count value in this chunk
*/
get ΔelemCount () {
return new DataView(this).getUint8(10);
}
/** Return the elem_count value in this chunk
*/
get elemCount () {
return new DataView(this).getUint8(11);
}
/** Return a TypedArray (e.g., Uint16Array, …) for the n-th Δelem in the chunk
*/
Δelem (n) {
if (this._ΔelemCaches[n]) return this._ΔelemCaches[n];
if (n < 0 || n >= this.ΔelemCount) throw new Error(`Invalid Δelem index: ${n}`);
const view = new DataView(this);
const count = this.jCount;
const ΔelemC = this.ΔelemCount;
const typeByte = view.getUint8(12 + n);
const baseCode = typeByte & 0xF;
const incrCode = typeByte >> 4;
const baseType = codeToType[baseCode];
const incrType = codeToType[incrCode];
if (!baseType || !incrType) throw new Error('Invalid type codes for Δelem');
// Find offset for initial value of this Δelem
let initialOffset = 12 + ΔelemC + this.elemCount;
for (let k = 0; k < n; k++) {
const tb = view.getUint8(12 + k);
const bc = tb & 0xF;
const bt = codeToType[bc];
initialOffset += typeToBytes[bt.name];
}
let current = readTypedValue(view, initialOffset, baseType);
// Advance to start of record data (after all initials and pad)
const recordOffset = this._getRecordOffset();
// Find offset for deltas of this Δelem (skip previous Δelems' delta blocks)
this._initBlockOffsets();
const deltaOffset = this._ΔelemBlockOffsets[n];
// Reconstruct the array
const arr = new baseType(count);
const isBigInt = baseType === BigInt64Array || baseType === BigUint64Array;
arr[0] = current;
for (let idx = 1; idx < count; idx++) {
let delta = readTypedValue(view, deltaOffset + idx * typeToBytes[incrType.name], incrType);
if (isBigInt) {
delta = BigInt(delta);
current += delta;
} else {
current += delta;
}
arr[idx] = current;
}
this._ΔelemCaches[n] = arr;
return arr;
}
/** Return a TypedArray (e.g., Uint16Array, …) for the n-th elem in the chunk
*/
elem (n) {
if (this._elemCaches[n]) return this._elemCaches[n];
if (n < 0 || n >= this.elemCount) throw new Error(`Invalid elem index: ${n}`);
const view = new DataView(this);
const count = this.jCount;
const ΔelemC = this.ΔelemCount;
const elemC = this.elemCount;
const typeCode = view.getUint8(12 + ΔelemC + n);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid type code for elem');
// Find offset for this elem's data block
this._initBlockOffsets();
const elemOffset = this._elemBlockOffsets[n];
// Create and populate the array
const arr = new type(count);
const bytes = typeToBytes[type.name];
for (let idx = 0; idx < count; idx++) {
arr[idx] = readTypedValue(view, elemOffset + idx * bytes, type);
}
this._elemCaches[n] = arr;
return arr;
}
getRecord (index) {
if (index < 0 || index >= this.jCount) throw new Error(`Invalid record index: ${index}`);
const arr = [this.udv, this.i, this.j0 + index * this.Δj];
for (let m = 0; m < this.ΔelemCount; m++) {
const values = this.Δelem(m);
arr.push(values[index]);
}
for (let m = 0; m < this.elemCount; m++) {
const values = this.elem(m);
arr.push(values[index]);
}
return arr;
}
[Symbol.iterator]() {
let index = 0;
const chunk = this;
return {
next() {
if (index < chunk.jCount) {
return { value: chunk.getRecord(index++), done: false };
} else {
return { done: true };
}
}
};
}
}
class DougalBinaryChunkInterleaved extends ArrayBuffer {
constructor(buffer, offset, length) {
super(length);
new Uint8Array(this).set(new Uint8Array(buffer, offset, length));
this._incrStrides = [];
this._elemStrides = [];
this._incrOffsets = [];
this._elemOffsets = [];
this._recordStride = 0;
this._recordOffset = null;
this._initStrides();
this._ΔelemCaches = new Array(this.ΔelemCount);
this._elemCaches = new Array(this.elemCount);
}
_getRecordOffset() {
if (this._recordOffset !== null) return this._recordOffset;
const view = new DataView(this);
const ΔelemC = this.ΔelemCount;
const elemC = this.elemCount;
let recordOffset = 12 + ΔelemC + elemC;
for (let k = 0; k < ΔelemC; k++) {
const tb = view.getUint8(12 + k);
const bc = tb & 0xF;
const bt = codeToType[bc];
recordOffset += typeToBytes[bt.name];
}
while (recordOffset % 4 !== 0) recordOffset++;
this._recordOffset = recordOffset;
return recordOffset;
}
_initStrides() {
const view = new DataView(this);
const ΔelemC = this.ΔelemCount;
const elemC = this.elemCount;
// Compute incr strides and offsets
let incrOffset = 0;
for (let k = 0; k < ΔelemC; k++) {
const typeByte = view.getUint8(12 + k);
const incrCode = typeByte >> 4;
const incrType = codeToType[incrCode];
if (!incrType) throw new Error('Invalid incr type code');
this._incrOffsets.push(incrOffset);
const bytes = typeToBytes[incrType.name];
this._incrStrides.push(bytes);
incrOffset += bytes;
this._recordStride += bytes;
}
// Compute elem strides and offsets
let elemOffset = incrOffset;
for (let k = 0; k < elemC; k++) {
const typeCode = view.getUint8(12 + ΔelemC + k);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid elem type code');
this._elemOffsets.push(elemOffset);
const bytes = typeToBytes[type.name];
this._elemStrides.push(bytes);
elemOffset += bytes;
this._recordStride += bytes;
}
}
get udv() {
return new DataView(this).getUint8(1);
}
get jCount() {
return new DataView(this).getUint16(2, true);
}
get i() {
return new DataView(this).getUint16(4, true);
}
get j0() {
return new DataView(this).getUint16(6, true);
}
get Δj() {
return new DataView(this).getInt16(8, true);
}
get ΔelemCount() {
return new DataView(this).getUint8(10);
}
get elemCount() {
return new DataView(this).getUint8(11);
}
Δelem(n) {
if (this._ΔelemCaches[n]) return this._ΔelemCaches[n];
if (n < 0 || n >= this.ΔelemCount) throw new Error(`Invalid Δelem index: ${n}`);
const view = new DataView(this);
const count = this.jCount;
const ΔelemC = this.ΔelemCount;
const typeByte = view.getUint8(12 + n);
const baseCode = typeByte & 0xF;
const incrCode = typeByte >> 4;
const baseType = codeToType[baseCode];
const incrType = codeToType[incrCode];
if (!baseType || !incrType) throw new Error('Invalid type codes for Δelem');
// Find offset for initial value of this Δelem
let initialOffset = 12 + ΔelemC + this.elemCount;
for (let k = 0; k < n; k++) {
const tb = view.getUint8(12 + k);
const bc = tb & 0xF;
const bt = codeToType[bc];
initialOffset += typeToBytes[bt.name];
}
let current = readTypedValue(view, initialOffset, baseType);
// Find offset to start of record data
const recordOffset = this._getRecordOffset();
// Use precomputed offset for this Δelem
const deltaOffset = recordOffset + this._incrOffsets[n];
// Reconstruct the array
const arr = new baseType(count);
const isBigInt = baseType === BigInt64Array || baseType === BigUint64Array;
arr[0] = current;
for (let idx = 1; idx < count; idx++) {
let delta = readTypedValue(view, deltaOffset + idx * this._recordStride, incrType);
if (isBigInt) {
delta = BigInt(delta);
current += delta;
} else {
current += delta;
}
arr[idx] = current;
}
this._ΔelemCaches[n] = arr;
return arr;
}
elem(n) {
if (this._elemCaches[n]) return this._elemCaches[n];
if (n < 0 || n >= this.elemCount) throw new Error(`Invalid elem index: ${n}`);
const view = new DataView(this);
const count = this.jCount;
const ΔelemC = this.ΔelemCount;
const typeCode = view.getUint8(12 + ΔelemC + n);
const type = codeToType[typeCode];
if (!type) throw new Error('Invalid type code for elem');
// Find offset to start of record data
const recordOffset = this._getRecordOffset();
// Use precomputed offset for this elem (relative to start of record data)
const elemOffset = recordOffset + this._elemOffsets[n];
// Create and populate the array
const arr = new type(count);
const bytes = typeToBytes[type.name];
for (let idx = 0; idx < count; idx++) {
arr[idx] = readTypedValue(view, elemOffset + idx * this._recordStride, type);
}
this._elemCaches[n] = arr;
return arr;
}
getRecord (index) {
if (index < 0 || index >= this.jCount) throw new Error(`Invalid record index: ${index}`);
const arr = [this.udv, this.i, this.j0 + index * this.Δj];
for (let m = 0; m < this.ΔelemCount; m++) {
const values = this.Δelem(m);
arr.push(values[index]);
}
for (let m = 0; m < this.elemCount; m++) {
const values = this.elem(m);
arr.push(values[index]);
}
return arr;
}
[Symbol.iterator]() {
let index = 0;
const chunk = this;
return {
next() {
if (index < chunk.jCount) {
return { value: chunk.getRecord(index++), done: false };
} else {
return { done: true };
}
}
};
}
}
module.exports = { DougalBinaryBundle, DougalBinaryChunkSequential, DougalBinaryChunkInterleaved }

View File

@@ -0,0 +1,327 @@
const codeToType = {
0: Int8Array,
1: Uint8Array,
2: Int16Array,
3: Uint16Array,
4: Int32Array,
5: Uint32Array,
7: Float32Array,
8: Float64Array,
9: BigInt64Array,
10: BigUint64Array
};
const typeToBytes = {
Int8Array: 1,
Uint8Array: 1,
Int16Array: 2,
Uint16Array: 2,
Int32Array: 4,
Uint32Array: 4,
Float32Array: 4,
Float64Array: 8,
BigInt64Array: 8,
BigUint64Array: 8
};
function sequential(binary) {
if (!(binary instanceof Uint8Array) || binary.length < 4) {
throw new Error('Invalid binary input');
}
const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
let offset = 0;
// Initialize result (assuming single i value for simplicity; extend for multiple i values if needed)
const result = { i: null, j: [], Δelems: [], elems: [] };
// Process bundles
while (offset < binary.length) {
// Read bundle header
if (offset + 4 > binary.length) throw new Error('Incomplete bundle header');
const bundleHeader = view.getUint32(offset, true);
if ((bundleHeader & 0xFF) !== 0x1C) throw new Error('Invalid bundle marker');
const bundleLength = bundleHeader >> 8;
offset += 4;
const bundleEnd = offset + bundleLength;
if (bundleEnd > binary.length) throw new Error('Bundle length exceeds input size');
// Process chunks in bundle
while (offset < bundleEnd) {
// Read chunk header
if (offset + 12 > bundleEnd) throw new Error('Incomplete chunk header');
const chunkType = view.getUint8(offset);
if (chunkType !== 0x11) throw new Error(`Unsupported chunk type: ${chunkType}`);
offset += 1; // Skip udv
offset += 1;
const count = view.getUint16(offset, true); offset += 2;
if (count > 65535) throw new Error('Chunk count exceeds 65535');
const iValue = view.getUint16(offset, true); offset += 2;
const j0 = view.getUint16(offset, true); offset += 2;
const Δj = view.getInt16(offset, true); offset += 2;
const ΔelemCount = view.getUint8(offset++); // Δelem_count
const elemCount = view.getUint8(offset++); // elem_count
// Set i value (assuming all chunks share the same i)
if (result.i === null) result.i = iValue;
else if (result.i !== iValue) throw new Error('Multiple i values not supported');
// Read preface (element types)
const ΔelemTypes = [];
for (let i = 0; i < ΔelemCount; i++) {
if (offset >= bundleEnd) throw new Error('Incomplete Δelem types');
const typeByte = view.getUint8(offset++);
const baseCode = typeByte & 0x0F;
const incrCode = typeByte >> 4;
if (!codeToType[baseCode] || !codeToType[incrCode]) {
throw new Error(`Invalid type code in Δelem: ${typeByte}`);
}
ΔelemTypes.push({ baseType: codeToType[baseCode], incrType: codeToType[incrCode] });
}
const elemTypes = [];
for (let i = 0; i < elemCount; i++) {
if (offset >= bundleEnd) throw new Error('Incomplete elem types');
const typeCode = view.getUint8(offset++);
if (!codeToType[typeCode]) throw new Error(`Invalid type code in elem: ${typeCode}`);
elemTypes.push(codeToType[typeCode]);
}
// Initialize Δelems and elems arrays if first chunk
if (!result.Δelems.length && ΔelemCount > 0) {
result.Δelems = Array(ΔelemCount).fill().map(() => []);
}
if (!result.elems.length && elemCount > 0) {
result.elems = Array(elemCount).fill().map(() => []);
}
// Read initial values for Δelems
const initialValues = [];
for (const { baseType } of ΔelemTypes) {
if (offset + typeToBytes[baseType.name] > bundleEnd) {
throw new Error('Incomplete initial values');
}
initialValues.push(readTypedValue(view, offset, baseType));
offset += typeToBytes[baseType.name];
}
// Skip padding
while (offset % 4 !== 0) {
if (offset >= bundleEnd) throw new Error('Incomplete padding after initial values');
offset++;
}
// Reconstruct j values
for (let idx = 0; idx < count; idx++) {
result.j.push(j0 + idx * Δj);
}
// Read record data (non-interleaved)
for (let i = 0; i < ΔelemCount; i++) {
let current = initialValues[i];
const values = result.Δelems[i];
const incrType = ΔelemTypes[i].incrType;
const isBigInt = typeof current === 'bigint';
for (let idx = 0; idx < count; idx++) {
if (offset + typeToBytes[incrType.name] > bundleEnd) {
throw new Error('Incomplete Δelem data');
}
let delta = readTypedValue(view, offset, incrType);
if (idx === 0) {
values.push(isBigInt ? Number(current) : current);
} else {
if (isBigInt) {
delta = BigInt(delta);
current += delta;
values.push(Number(current));
} else {
current += delta;
values.push(current);
}
}
offset += typeToBytes[incrType.name];
}
}
for (let i = 0; i < elemCount; i++) {
const values = result.elems[i];
const type = elemTypes[i];
const isBigInt = type === BigInt64Array || type === BigUint64Array;
for (let idx = 0; idx < count; idx++) {
if (offset + typeToBytes[type.name] > bundleEnd) {
throw new Error('Incomplete elem data');
}
let value = readTypedValue(view, offset, type);
values.push(isBigInt ? Number(value) : value);
offset += typeToBytes[type.name];
}
}
// Skip padding
while (offset % 4 !== 0) {
if (offset >= bundleEnd) throw new Error('Incomplete padding after record data');
offset++;
}
}
}
return result;
}
function interleaved(binary) {
if (!(binary instanceof Uint8Array) || binary.length < 4) {
throw new Error('Invalid binary input');
}
const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
let offset = 0;
// Initialize result (assuming single i value for simplicity; extend for multiple i values if needed)
const result = { i: null, j: [], Δelems: [], elems: [] };
// Process bundles
while (offset < binary.length) {
// Read bundle header
if (offset + 4 > binary.length) throw new Error('Incomplete bundle header');
const bundleHeader = view.getUint32(offset, true);
if ((bundleHeader & 0xFF) !== 0x1C) throw new Error('Invalid bundle marker');
const bundleLength = bundleHeader >> 8;
offset += 4;
const bundleEnd = offset + bundleLength;
if (bundleEnd > binary.length) throw new Error('Bundle length exceeds input size');
// Process chunks in bundle
while (offset < bundleEnd) {
// Read chunk header
if (offset + 12 > bundleEnd) throw new Error('Incomplete chunk header');
const chunkType = view.getUint8(offset);
if (chunkType !== 0x12) throw new Error(`Unsupported chunk type: ${chunkType}`);
offset += 1; // Skip udv
offset += 1;
const count = view.getUint16(offset, true); offset += 2;
if (count > 65535) throw new Error('Chunk count exceeds 65535');
const iValue = view.getUint16(offset, true); offset += 2;
const j0 = view.getUint16(offset, true); offset += 2;
const Δj = view.getInt16(offset, true); offset += 2;
const ΔelemCount = view.getUint8(offset++); // Δelem_count
const elemCount = view.getUint8(offset++); // elem_count
// Set i value (assuming all chunks share the same i)
if (result.i === null) result.i = iValue;
else if (result.i !== iValue) throw new Error('Multiple i values not supported');
// Read preface (element types)
const ΔelemTypes = [];
for (let i = 0; i < ΔelemCount; i++) {
if (offset >= bundleEnd) throw new Error('Incomplete Δelem types');
const typeByte = view.getUint8(offset++);
const baseCode = typeByte & 0x0F;
const incrCode = typeByte >> 4;
if (!codeToType[baseCode] || !codeToType[incrCode]) {
throw new Error(`Invalid type code in Δelem: ${typeByte}`);
}
ΔelemTypes.push({ baseType: codeToType[baseCode], incrType: codeToType[incrCode] });
}
const elemTypes = [];
for (let i = 0; i < elemCount; i++) {
if (offset >= bundleEnd) throw new Error('Incomplete elem types');
const typeCode = view.getUint8(offset++);
if (!codeToType[typeCode]) throw new Error(`Invalid type code in elem: ${typeCode}`);
elemTypes.push(codeToType[typeCode]);
}
// Initialize Δelems and elems arrays if first chunk
if (!result.Δelems.length && ΔelemCount > 0) {
result.Δelems = Array(ΔelemCount).fill().map(() => []);
}
if (!result.elems.length && elemCount > 0) {
result.elems = Array(elemCount).fill().map(() => []);
}
// Read initial values for Δelems
const initialValues = [];
for (const { baseType } of ΔelemTypes) {
if (offset + typeToBytes[baseType.name] > bundleEnd) {
throw new Error('Incomplete initial values');
}
initialValues.push(readTypedValue(view, offset, baseType));
offset += typeToBytes[baseType.name];
}
// Skip padding
while (offset % 4 !== 0) {
if (offset >= bundleEnd) throw new Error('Incomplete padding after initial values');
offset++;
}
// Reconstruct j values
for (let idx = 0; idx < count; idx++) {
result.j.push(j0 + idx * Δj);
}
// Read interleaved record data
for (let idx = 0; idx < count; idx++) {
// Read Δelems
for (let i = 0; i < ΔelemCount; i++) {
const values = result.Δelems[i];
const incrType = ΔelemTypes[i].incrType;
const isBigInt = typeof initialValues[i] === 'bigint';
if (offset + typeToBytes[incrType.name] > bundleEnd) {
throw new Error('Incomplete Δelem data');
}
let delta = readTypedValue(view, offset, incrType);
offset += typeToBytes[incrType.name];
if (idx === 0) {
values.push(isBigInt ? Number(initialValues[i]) : initialValues[i]);
} else {
if (isBigInt) {
delta = BigInt(delta);
initialValues[i] += delta;
values.push(Number(initialValues[i]));
} else {
initialValues[i] += delta;
values.push(initialValues[i]);
}
}
}
// Read elems
for (let i = 0; i < elemCount; i++) {
const values = result.elems[i];
const type = elemTypes[i];
const isBigInt = type === BigInt64Array || type === BigUint64Array;
if (offset + typeToBytes[type.name] > bundleEnd) {
throw new Error('Incomplete elem data');
}
let value = readTypedValue(view, offset, type);
values.push(isBigInt ? Number(value) : value);
offset += typeToBytes[type.name];
}
}
// Skip padding
while (offset % 4 !== 0) {
if (offset >= bundleEnd) throw new Error('Incomplete padding after record data');
offset++;
}
}
}
return result;
}
function readTypedValue(view, offset, type) {
switch (type) {
case Int8Array: return view.getInt8(offset);
case Uint8Array: return view.getUint8(offset);
case Int16Array: return view.getInt16(offset, true);
case Uint16Array: return view.getUint16(offset, true);
case Int32Array: return view.getInt32(offset, true);
case Uint32Array: return view.getUint32(offset, true);
case Float32Array: return view.getFloat32(offset, true);
case Float64Array: return view.getFloat64(offset, true);
case BigInt64Array: return view.getBigInt64(offset, true);
case BigUint64Array: return view.getBigUint64(offset, true);
default: throw new Error(`Unsupported type: ${type.name}`);
}
}
module.exports = { sequential, interleaved };

View File

@@ -0,0 +1,380 @@
const typeToCode = {
Int8Array: 0,
Uint8Array: 1,
Int16Array: 2,
Uint16Array: 3,
Int32Array: 4,
Uint32Array: 5,
Float32Array: 7, // Float16 not natively supported in JS, use Float32
Float64Array: 8,
BigInt64Array: 9,
BigUint64Array: 10
};
const typeToBytes = {
Int8Array: 1,
Uint8Array: 1,
Int16Array: 2,
Uint16Array: 2,
Int32Array: 4,
Uint32Array: 4,
Float32Array: 4,
Float64Array: 8,
BigInt64Array: 8,
BigUint64Array: 8
};
function sequential(json, iGetter, jGetter, Δelems = [], elems = [], udv = 0) {
if (!Array.isArray(json) || !json.length) return new Uint8Array(0);
if (typeof iGetter !== 'function' || typeof jGetter !== 'function') throw new Error('i and j must be getter functions');
Δelems.forEach((elem, idx) => {
if (typeof elem.key !== 'function') throw new Error(`Δelems[${idx}].key must be a getter function`);
});
elems.forEach((elem, idx) => {
if (typeof elem.key !== 'function') throw new Error(`elems[${idx}].key must be a getter function`);
});
// Group records by i value
const groups = new Map();
for (const record of json) {
const iValue = iGetter(record);
if (iValue == null) throw new Error('Missing i value from getter');
if (!groups.has(iValue)) groups.set(iValue, []);
groups.get(iValue).push(record);
}
const maxBundleSize = 0xFFFFFF; // Max bundle length (24 bits)
const buffers = [];
// Process each group (i value)
for (const [iValue, records] of groups) {
// Sort records by j to ensure consistent order
records.sort((a, b) => jGetter(a) - jGetter(b));
const jValues = records.map(jGetter);
if (jValues.some(v => v == null)) throw new Error('Missing j value from getter');
// Split records into chunks based on Δj continuity
const chunks = [];
let currentChunk = [records[0]];
let currentJ0 = jValues[0];
let currentΔj = records.length > 1 ? jValues[1] - jValues[0] : 0;
for (let idx = 1; idx < records.length; idx++) {
const chunkIndex = chunks.reduce((sum, c) => sum + c.records.length, 0);
const expectedJ = currentJ0 + (idx - chunkIndex) * currentΔj;
if (jValues[idx] !== expectedJ || idx - chunkIndex >= 65536) {
chunks.push({ records: currentChunk, j0: currentJ0, Δj: currentΔj });
currentChunk = [records[idx]];
currentJ0 = jValues[idx];
currentΔj = idx + 1 < records.length ? jValues[idx + 1] - jValues[idx] : 0;
} else {
currentChunk.push(records[idx]);
}
}
if (currentChunk.length > 0) {
chunks.push({ records: currentChunk, j0: currentJ0, Δj: currentΔj });
}
// Calculate total size for all chunks in this group by simulating offsets
const chunkSizes = chunks.map(({ records: chunkRecords }) => {
if (chunkRecords.length > 65535) throw new Error(`Chunk size exceeds 65535 for i=${iValue}`);
let simulatedOffset = 0; // Relative to chunk start
simulatedOffset += 12; // Header
simulatedOffset += Δelems.length + elems.length; // Preface
simulatedOffset += Δelems.reduce((sum, e) => sum + typeToBytes[e.baseType.name], 0); // Initial values
while (simulatedOffset % 4 !== 0) simulatedOffset++; // Pad after initial
simulatedOffset += chunkRecords.length * (
Δelems.reduce((sum, e) => sum + typeToBytes[e.incrType.name], 0) +
elems.reduce((sum, e) => sum + typeToBytes[e.type.name], 0)
); // Record data
while (simulatedOffset % 4 !== 0) simulatedOffset++; // Pad after record
return simulatedOffset;
});
const totalChunkSize = chunkSizes.reduce((sum, size) => sum + size, 0);
// Start a new bundle if needed
const lastBundle = buffers[buffers.length - 1];
if (!lastBundle || lastBundle.offset + totalChunkSize > maxBundleSize) {
buffers.push({ offset: 4, buffer: null, view: null });
}
// Initialize DataView for current bundle
const currentBundle = buffers[buffers.length - 1];
if (!currentBundle.buffer) {
const requiredSize = totalChunkSize + 4;
currentBundle.buffer = new ArrayBuffer(requiredSize);
currentBundle.view = new DataView(currentBundle.buffer);
}
// Process each chunk
for (const { records: chunkRecords, j0, Δj } of chunks) {
const chunkSize = chunkSizes.shift();
// Ensure buffer is large enough
if (currentBundle.offset + chunkSize > currentBundle.buffer.byteLength) {
const newSize = currentBundle.offset + chunkSize;
const newBuffer = new ArrayBuffer(newSize);
new Uint8Array(newBuffer).set(new Uint8Array(currentBundle.buffer));
currentBundle.buffer = newBuffer;
currentBundle.view = new DataView(newBuffer);
}
// Write chunk header
let offset = currentBundle.offset;
currentBundle.view.setUint8(offset++, 0x11); // Chunk type
currentBundle.view.setUint8(offset++, udv); // udv
currentBundle.view.setUint16(offset, chunkRecords.length, true); offset += 2; // count
currentBundle.view.setUint16(offset, iValue, true); offset += 2; // i
currentBundle.view.setUint16(offset, j0, true); offset += 2; // j0
currentBundle.view.setInt16(offset, Δj, true); offset += 2; // Δj
currentBundle.view.setUint8(offset++, Δelems.length); // Δelem_count
currentBundle.view.setUint8(offset++, elems.length); // elem_count
// Write chunk preface (element types)
for (const elem of Δelems) {
const baseCode = typeToCode[elem.baseType.name];
const incrCode = typeToCode[elem.incrType.name];
currentBundle.view.setUint8(offset++, (incrCode << 4) | baseCode);
}
for (const elem of elems) {
currentBundle.view.setUint8(offset++, typeToCode[elem.type.name]);
}
// Write initial values for Δelems
for (const elem of Δelems) {
const value = elem.key(chunkRecords[0]);
if (value == null) throw new Error('Missing Δelem value from getter');
writeTypedValue(currentBundle.view, offset, value, elem.baseType);
offset += typeToBytes[elem.baseType.name];
}
// Pad to 4-byte boundary
while (offset % 4 !== 0) currentBundle.view.setUint8(offset++, 0);
// Write record data (non-interleaved)
for (const elem of Δelems) {
let prev = elem.key(chunkRecords[0]);
for (let idx = 0; idx < chunkRecords.length; idx++) {
const value = idx === 0 ? 0 : elem.key(chunkRecords[idx]) - prev;
writeTypedValue(currentBundle.view, offset, value, elem.incrType);
offset += typeToBytes[elem.incrType.name];
prev = elem.key(chunkRecords[idx]);
}
}
for (const elem of elems) {
for (const record of chunkRecords) {
const value = elem.key(record);
if (value == null) throw new Error('Missing elem value from getter');
writeTypedValue(currentBundle.view, offset, value, elem.type);
offset += typeToBytes[elem.type.name];
}
}
// Pad to 4-byte boundary
while (offset % 4 !== 0) currentBundle.view.setUint8(offset++, 0);
// Update bundle offset
currentBundle.offset = offset;
}
// Update bundle header
currentBundle.view.setUint32(0, 0x1C | ((currentBundle.offset - 4) << 8), true);
}
// Combine buffers into final Uint8Array
const finalLength = buffers.reduce((sum, b) => sum + b.offset, 0);
const result = new Uint8Array(finalLength);
let offset = 0;
for (const { buffer, offset: bundleOffset } of buffers) {
result.set(new Uint8Array(buffer, 0, bundleOffset), offset);
offset += bundleOffset;
}
return result;
}
function interleaved(json, iGetter, jGetter, Δelems = [], elems = [], udv = 0) {
if (!Array.isArray(json) || !json.length) return new Uint8Array(0);
if (typeof iGetter !== 'function' || typeof jGetter !== 'function') throw new Error('i and j must be getter functions');
Δelems.forEach((elem, idx) => {
if (typeof elem.key !== 'function') throw new Error(`Δelems[${idx}].key must be a getter function`);
});
elems.forEach((elem, idx) => {
if (typeof elem.key !== 'function') throw new Error(`elems[${idx}].key must be a getter function`);
});
// Group records by i value
const groups = new Map();
for (const record of json) {
const iValue = iGetter(record);
if (iValue == null) throw new Error('Missing i value from getter');
if (!groups.has(iValue)) groups.set(iValue, []);
groups.get(iValue).push(record);
}
const maxBundleSize = 0xFFFFFF; // Max bundle length (24 bits)
const buffers = [];
// Process each group (i value)
for (const [iValue, records] of groups) {
// Sort records by j to ensure consistent order
records.sort((a, b) => jGetter(a) - jGetter(b));
const jValues = records.map(jGetter);
if (jValues.some(v => v == null)) throw new Error('Missing j value from getter');
// Split records into chunks based on Δj continuity
const chunks = [];
let currentChunk = [records[0]];
let currentJ0 = jValues[0];
let currentΔj = records.length > 1 ? jValues[1] - jValues[0] : 0;
for (let idx = 1; idx < records.length; idx++) {
const chunkIndex = chunks.reduce((sum, c) => sum + c.records.length, 0);
const expectedJ = currentJ0 + (idx - chunkIndex) * currentΔj;
if (jValues[idx] !== expectedJ || idx - chunkIndex >= 65536) {
chunks.push({ records: currentChunk, j0: currentJ0, Δj: currentΔj });
currentChunk = [records[idx]];
currentJ0 = jValues[idx];
currentΔj = idx + 1 < records.length ? jValues[idx + 1] - jValues[idx] : 0;
} else {
currentChunk.push(records[idx]);
}
}
if (currentChunk.length > 0) {
chunks.push({ records: currentChunk, j0: currentJ0, Δj: currentΔj });
}
// Calculate total size for all chunks in this group by simulating offsets
const chunkSizes = chunks.map(({ records: chunkRecords }) => {
if (chunkRecords.length > 65535) throw new Error(`Chunk size exceeds 65535 for i=${iValue}`);
let simulatedOffset = 0; // Relative to chunk start
simulatedOffset += 12; // Header
simulatedOffset += Δelems.length + elems.length; // Preface
simulatedOffset += Δelems.reduce((sum, e) => sum + typeToBytes[e.baseType.name], 0); // Initial values
while (simulatedOffset % 4 !== 0) simulatedOffset++; // Pad after initial
simulatedOffset += chunkRecords.length * (
Δelems.reduce((sum, e) => sum + typeToBytes[e.incrType.name], 0) +
elems.reduce((sum, e) => sum + typeToBytes[e.type.name], 0)
); // Interleaved record data
while (simulatedOffset % 4 !== 0) simulatedOffset++; // Pad after record
return simulatedOffset;
});
const totalChunkSize = chunkSizes.reduce((sum, size) => sum + size, 0);
// Start a new bundle if needed
const lastBundle = buffers[buffers.length - 1];
if (!lastBundle || lastBundle.offset + totalChunkSize > maxBundleSize) {
buffers.push({ offset: 4, buffer: null, view: null });
}
// Initialize DataView for current bundle
const currentBundle = buffers[buffers.length - 1];
if (!currentBundle.buffer) {
const requiredSize = totalChunkSize + 4;
currentBundle.buffer = new ArrayBuffer(requiredSize);
currentBundle.view = new DataView(currentBundle.buffer);
}
// Process each chunk
for (const { records: chunkRecords, j0, Δj } of chunks) {
const chunkSize = chunkSizes.shift();
// Ensure buffer is large enough
if (currentBundle.offset + chunkSize > currentBundle.buffer.byteLength) {
const newSize = currentBundle.offset + chunkSize;
const newBuffer = new ArrayBuffer(newSize);
new Uint8Array(newBuffer).set(new Uint8Array(currentBundle.buffer));
currentBundle.buffer = newBuffer;
currentBundle.view = new DataView(newBuffer);
}
// Write chunk header
let offset = currentBundle.offset;
currentBundle.view.setUint8(offset++, 0x12); // Chunk type
currentBundle.view.setUint8(offset++, udv); // udv
currentBundle.view.setUint16(offset, chunkRecords.length, true); offset += 2; // count
currentBundle.view.setUint16(offset, iValue, true); offset += 2; // i
currentBundle.view.setUint16(offset, j0, true); offset += 2; // j0
currentBundle.view.setInt16(offset, Δj, true); offset += 2; // Δj
currentBundle.view.setUint8(offset++, Δelems.length); // Δelem_count
currentBundle.view.setUint8(offset++, elems.length); // elem_count
// Write chunk preface (element types)
for (const elem of Δelems) {
const baseCode = typeToCode[elem.baseType.name];
const incrCode = typeToCode[elem.incrType.name];
currentBundle.view.setUint8(offset++, (incrCode << 4) | baseCode);
}
for (const elem of elems) {
currentBundle.view.setUint8(offset++, typeToCode[elem.type.name]);
}
// Write initial values for Δelems
for (const elem of Δelems) {
const value = elem.key(chunkRecords[0]);
if (value == null) throw new Error('Missing Δelem value from getter');
writeTypedValue(currentBundle.view, offset, value, elem.baseType);
offset += typeToBytes[elem.baseType.name];
}
// Pad to 4-byte boundary
while (offset % 4 !== 0) currentBundle.view.setUint8(offset++, 0);
// Write interleaved record data
const prevValues = Δelems.map(elem => elem.key(chunkRecords[0]));
for (let idx = 0; idx < chunkRecords.length; idx++) {
// Write Δelems increments
for (let i = 0; i < Δelems.length; i++) {
const elem = Δelems[i];
const value = idx === 0 ? 0 : elem.key(chunkRecords[idx]) - prevValues[i];
writeTypedValue(currentBundle.view, offset, value, elem.incrType);
offset += typeToBytes[elem.incrType.name];
prevValues[i] = elem.key(chunkRecords[idx]);
}
// Write elems
for (const elem of elems) {
const value = elem.key(chunkRecords[idx]);
if (value == null) throw new Error('Missing elem value from getter');
writeTypedValue(currentBundle.view, offset, value, elem.type);
offset += typeToBytes[elem.type.name];
}
}
// Pad to 4-byte boundary
while (offset % 4 !== 0) currentBundle.view.setUint8(offset++, 0);
// Update bundle offset
currentBundle.offset = offset;
}
// Update bundle header
currentBundle.view.setUint32(0, 0x1C | ((currentBundle.offset - 4) << 8), true);
}
// Combine buffers into final Uint8Array
const finalLength = buffers.reduce((sum, b) => sum + b.offset, 0);
const result = new Uint8Array(finalLength);
let offset = 0;
for (const { buffer, offset: bundleOffset } of buffers) {
result.set(new Uint8Array(buffer, 0, bundleOffset), offset);
offset += bundleOffset;
}
return result;
}
function writeTypedValue(view, offset, value, type) {
switch (type) {
case Int8Array: view.setInt8(offset, value); break;
case Uint8Array: view.setUint8(offset, value); break;
case Int16Array: view.setInt16(offset, value, true); break;
case Uint16Array: view.setUint16(offset, value, true); break;
case Int32Array: view.setInt32(offset, value, true); break;
case Uint32Array: view.setUint32(offset, value, true); break;
case Float32Array: view.setFloat32(offset, value, true); break;
case Float64Array: view.setFloat64(offset, value, true); break;
case BigInt64Array: view.setBigInt64(offset, BigInt(value), true); break;
case BigUint64Array: view.setBigUint64(offset, BigInt(value), true); break;
default: throw new Error(`Unsupported type: ${type.name}`);
}
}
module.exports = { sequential, interleaved };

View File

@@ -0,0 +1,139 @@
/** Binary encoder
*
* This module encodes scalar data from a grid-like source
* into a packed binary format for bandwidth efficiency and
* speed of access.
*
* Data are indexed by i & j values, with "i" being constant
* (e.g., a sequence or line number) and "j" expected to change
* by a constant, linear amount (e.g., point numbers). All data
* from consecutive "j" values will be encoded as a single array
* (or series of arrays if multiple values are encoded).
* If there is a jump in the "j" progression, a new "chunk" will
* be started with a new array (or series of arrays).
*
* Multiple values may be encoded per (i, j) pair, using any of
* the types supported by JavaScript's TypedArray except for
* Float16 and Uint8Clamped. Each variable can be encoded with
* a different size.
*
* Values may be encoded directly or as deltas from an initial
* value. The latter is particularly efficient when dealing with
* monotonically incrementing data, such as timestamps.
*
* The conceptual packet format for sequentially encoded data
* looks like this:
*
* <msg-type> <count: x> <i> <j0> <Δj>
*
* <Δelement_count: y>
* <element_count: z>
*
* <Δelement_1_type_base> … <Δelement_y_type_base>
* <Δelement_1_type_incr> … <Δelement_y_type_incr>
* <elem_1_type> … <elem_z_type>
*
* <Δelement_1_first> … <Δelement_z_first>
*
* <Δelem_1_0> … <Δelem_1_x>
* …
* <Δelem_y_0> … <Δelem_y_x>
* <elem_1_0> … <elem_1_x>
* …
* <elem_z_0> … <elem_z_x>
*
*
* The conceptual packet format for interleaved encoded data
* looks like this:
*
*
* <msg-type> <count: x> <i> <j0> <Δj>
*
* <Δelement_count: y>
* <element_count: z>
*
* <Δelement_1_type_base> … <Δelement_y_type_base>
* <Δelement_1_type_incr> … <Δelement_y_type_incr>
* <elem_1_type> … <elem_z_type>
*
* <Δelement_1_first> … <Δelement_y_first>
*
* <Δelem_1_0> <Δelem_2_0> … <Δelem_y_0> <elem_1_0> <elem_2_0> … <elem_z_0>
* <Δelem_1_1> <Δelem_2_1> … <Δelem_y_1> <elem_1_1> <elem_2_1> … <elem_z_1>
* …
* <Δelem_1_x> <Δelem_2_x> … <Δelem_y_x> <elem_1_x> <elem_2_x> … <elem_z_x>
*
*
* Usage example:
*
* json = [
* {
* sequence: 7,
* sailline: 5354,
* line: 5356,
* point: 1068,
* tstamp: 1695448704372,
* objrefraw: 3,
* objreffinal: 4
* },
* {
* sequence: 7,
* sailline: 5354,
* line: 5352,
* point: 1070,
* tstamp: 1695448693612,
* objrefraw: 2,
* objreffinal: 3
* },
* {
* sequence: 7,
* sailline: 5354,
* line: 5356,
* point: 1072,
* tstamp: 1695448684624,
* objrefraw: 3,
* objreffinal: 4
* }
* ];
*
* deltas = [
* { key: el => el.tstamp, baseType: BigUint64Array, incrType: Int16Array }
* ];
*
* elems = [
* { key: el => el.objrefraw, type: Uint8Array },
* { key: el => el.objreffinal, type: Uint8Array }
* ];
*
* i = el => el.sequence;
*
* j = el => el.point;
*
* bundle = encode(json, i, j, deltas, elems);
*
* // bundle:
*
* Uint8Array(40) [
* 36, 0, 0, 28, 17, 0, 3, 0, 7, 0,
* 44, 4, 2, 0, 1, 2, 42, 1, 1, 116,
* 37, 158, 192, 138, 1, 0, 0, 0, 0, 0,
* 248, 213, 228, 220, 3, 2, 3, 4, 3, 4
* ]
*
* decode(bundle);
*
* {
* i: 7,
* j: [ 1068, 1070, 1072 ],
* 'Δelems': [ [ 1695448704372, 1695448693612, 1695448684624 ] ],
* elems: [ [ 3, 2, 3 ], [ 4, 3, 4 ] ]
* }
*
*/
module.exports = {
encode: {...require('./encode')},
decode: {...require('./decode')},
...require('./classes')
};

View File

@@ -0,0 +1,12 @@
{
"name": "@dougal/binary",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
}

View File

@@ -0,0 +1,25 @@
class ConcurrencyLimiter {
constructor(maxConcurrent) {
this.maxConcurrent = maxConcurrent;
this.active = 0;
this.queue = [];
}
async enqueue(task) {
if (this.active >= this.maxConcurrent) {
await new Promise(resolve => this.queue.push(resolve));
}
this.active++;
try {
return await task();
} finally {
this.active--;
if (this.queue.length > 0) {
this.queue.shift()();
}
}
}
}
module.exports = ConcurrencyLimiter;

View File

@@ -0,0 +1,12 @@
{
"name": "@dougal/concurrency",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
}

View File

@@ -1,24 +0,0 @@
{
"name": "@dougal/user",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@dougal/user",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@dougal/organisations": "file:../organisations"
}
},
"../organisations": {
"version": "1.0.0",
"license": "ISC"
},
"node_modules/@dougal/organisations": {
"resolved": "../organisations",
"link": true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,14 @@
"build": "vue-cli-service build"
},
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.13",
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/mesh-layers": "^9.1.14",
"@dougal/binary": "file:../../../modules/@dougal/binary",
"@dougal/concurrency": "file:../../../modules/@dougal/concurrency",
"@dougal/organisations": "file:../../../modules/@dougal/organisations",
"@dougal/user": "file:../../../modules/@dougal/user",
"@loaders.gl/obj": "^4.3.4",
"@mdi/font": "^7.2.96",
"buffer": "^6.0.3",
"core-js": "^3.6.5",
@@ -19,6 +25,7 @@
"leaflet-arrowheads": "^1.2.2",
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"lodash.debounce": "^4.0.8",
"marked": "^9.1.4",
"path-browserify": "^1.0.1",
"plotly.js-dist": "^2.27.0",

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
:color="snackColour"
:timeout="6000"
>
{{ snackText }}
<div v-html="snackText"></div>
<template v-slot:action="{ attrs }">
<v-btn
text
@@ -52,9 +52,8 @@ export default {
}),
computed: {
snackText () { return this.$store.state.snack.snackText },
snackText () { return this.$root.markdownInline(this.$store.state.snack.snackText) },
snackColour () { return this.$store.state.snack.snackColour },
...mapGetters(["serverEvent"])
},
watch: {
@@ -77,24 +76,41 @@ export default {
this.$store.commit('setSnackText', "");
}
},
async serverEvent (event) {
if (event.channel == "project" && event.payload?.schema == "public") {
// Projects changed in some way or another
await this.refreshProjects();
} else if (event.channel == ".jwt" && event.payload?.token) {
await this.setCredentials({token: event.payload?.token});
}
}
},
methods: {
handleJWT (context, {payload}) {
this.setCredentials({token: payload.token});
},
handleProject (context, {payload}) {
if (payload?.table == "public") {
this.refreshProjects();
}
},
registerNotificationHandlers () {
this.$store.dispatch('registerHandler', {
table: '.jwt',
handler: this.handleJWT
});
this.$store.dispatch('registerHandler', {
table: 'project',
handler: this.handleProject
});
},
...mapActions(["setCredentials", "refreshProjects"])
},
async mounted () {
// Local Storage values are always strings
this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true";
this.registerNotificationHandlers();
await this.setCredentials();
this.refreshProjects();
}

View File

@@ -9,8 +9,17 @@
<v-spacer></v-spacer>
<template v-if="isFrontendRemote">
<template v-if="serverConnected">
<v-icon v-if="isGatewayReliable" class="mr-6" title="Connected to server via gateway">mdi-cloud-outline</v-icon>
<v-icon v-else class="mr-6" color="orange" title="Gateway connection is unreliable. Expect outages.">mdi-cloud-off</v-icon>
</template>
<v-icon v-else class="mr-6" color="red" :title="`Server connection lost: the gateway cannot reach the remote server.\nWe will reconnect automatically when the link with the remote server is restored.`">mdi-cloud-off</v-icon>
</template>
<template v-else>
<v-icon v-if="serverConnected" class="mr-6" small title="Connected to server">mdi-lan-connect</v-icon>
<v-icon v-else class="mr-6" small color="red" title="Server connection lost (we'll reconnect automatically when the server comes back)">mdi-lan-disconnect</v-icon>
<v-icon v-else class="mr-6" small color="red" :title="`Server connection lost.\nWe will reconnect automatically when the server comes back.`">mdi-lan-disconnect</v-icon>
</template>
<dougal-notifications-control class="mr-6"></dougal-notifications-control>
@@ -51,13 +60,39 @@ export default {
DougalNotificationsControl
},
data () {
return {
lastGatewayErrorTimestamp: 0,
gatewayErrorSilencePeriod: 60000,
}
},
computed: {
year () {
const date = new Date();
return date.getUTCFullYear();
},
...mapState({serverConnected: state => state.notify.serverConnected})
...mapState({
serverConnected: state => state.notify.serverConnected,
isFrontendRemote: state => state.api.serverInfo?.["remote-frontend"] ?? false,
isGatewayReliable: state => state.api.isGatewayReliable
})
},
watch: {
isGatewayReliable (val) {
if (val === false) {
const elapsed = Date.now() - this.lastGatewayErrorTimestamp;
const lastGatewayErrorTimestamp = Date.now();
if (elapsed > this.gatewayErrorSilencePeriod) {
this.$root.showSnack("Gateway error", "warning");
}
}
}
}
};
</script>

View File

@@ -3,8 +3,10 @@
<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>
@@ -57,8 +59,8 @@ export default {
graph: [],
busy: false,
resizeObserver: null,
scatterplot: false,
histogram: false
scatterplot: true,
histogram: true
};
},

View File

@@ -3,8 +3,10 @@
<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>
@@ -59,7 +61,7 @@ export default {
busy: false,
resizeObserver: null,
shotpoint: true,
violinplot: false
violinplot: true
};
},

View File

@@ -3,8 +3,10 @@
<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>
@@ -59,7 +61,7 @@ export default {
busy: false,
resizeObserver: null,
shotpoint: true,
violinplot: false
violinplot: true
};
},

View File

@@ -3,8 +3,10 @@
<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>
@@ -59,7 +61,7 @@ export default {
busy: false,
resizeObserver: null,
shotpoint: true,
violinplot: false
violinplot: true
};
},

View File

@@ -39,7 +39,8 @@ export default {
default:
return {
editable: false,
displaylogo: false
displaylogo: false,
responsive: true
};
}
},
@@ -48,7 +49,8 @@ export default {
const base = {
font: {
color: this.$vuetify.theme.isDark ? "#fff" : undefined
}
},
autosize: true
};
switch (this.facet) {
@@ -274,18 +276,25 @@ export default {
replot () {
if (this.plotted) {
const ref = this.$refs.graph;
if (ref && ref.clientWidth > 0 && ref.clientHeight > 0) {
Plotly.relayout(ref, {
width: ref.clientWidth,
height: ref.clientHeight
});
}
}
}
},
mounted () {
this.$nextTick( () => {
if (this.items?.length) {
this.plot();
}
this.resizeObserver = new ResizeObserver(this.replot)
this.resizeObserver.observe(this.$refs.graph);
});
},
beforeDestroy () {

View File

@@ -36,7 +36,8 @@ export default {
config () {
return {
editable: false,
displaylogo: false
displaylogo: false,
responsive: true
};
},
@@ -53,7 +54,8 @@ export default {
title: "Time (s)"
},
plot_bgcolor:"rgba(0,0,0,0)",
paper_bgcolor:"rgba(0,0,0,0)"
paper_bgcolor:"rgba(0,0,0,0)",
autosize: true
};
},
@@ -154,11 +156,13 @@ export default {
replot () {
if (this.plotted) {
const ref = this.$refs.graph;
if (ref && ref.clientWidth > 0 && ref.clientHeight > 0) {
Plotly.relayout(ref, {
width: ref.clientWidth,
height: ref.clientHeight
});
}
}
},
stats (x, y, src_number) {
@@ -190,8 +194,13 @@ export default {
},
mounted () {
this.$nextTick( () => {
if (this.items?.length) {
this.plot();
}
this.resizeObserver = new ResizeObserver(this.replot)
this.resizeObserver.observe(this.$refs.graph);
});
},
beforeDestroy () {

View File

@@ -0,0 +1,187 @@
<template>
<v-card v-if="comparison" class="ma-1">
<v-card-title>Comparison Summary: Baseline {{ baseline.pid }} vs Monitor {{ monitor.pid }}</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<h3>Deviation Statistics</h3>
<v-simple-table dense>
<template v-slot:default>
<thead>
<tr>
<th>Metric</th>
<th>I (m)</th>
<th>J (m)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Mean (μ)</td>
<td>{{ comparison['μ'][0].toFixed(3) }}</td>
<td>{{ comparison['μ'][1].toFixed(3) }}</td>
</tr>
<tr>
<td>Std Dev (σ)</td>
<td>{{ comparison['σ'][0].toFixed(3) }}</td>
<td>{{ comparison['σ'][1].toFixed(3) }}</td>
</tr>
<tr>
<td>RMS</td>
<td>{{ comparison.rms[0].toFixed(3) }}</td>
<td>{{ comparison.rms[1].toFixed(3) }}</td>
</tr>
</tbody>
</template>
</v-simple-table>
<h3 class="mt-4">Error distribution</h3>
<ul>
<li title="Relative to I-axis positive direction">Primary Direction: {{ (comparison.primaryDirection * 180 / Math.PI).toFixed(2) }}°</li>
<li>Anisotropy: {{ comparison.anisotropy.toFixed(2) }}</li>
<li title="Length of the semi-major axis of the error ellipse">Semi-Major Axis: {{ semiMajorAxis.toFixed(2) }} m</li>
<li title="Length of the semi-minor axis of the error ellipse">Semi-Minor Axis: {{ semiMinorAxis.toFixed(2) }} m</li>
<li title="Area of the error ellipse">Error Ellipse Area: {{ ellipseArea.toFixed(2) }} </li>
</ul>
<h3 class="mt-4">Counts</h3>
<ul>
<li title="Unique line / point pairs found in both projects">Common Points: {{ comparison.common }}</li>
<li title="Total number of points compared, including reshoots, infills, etc.">Comparison Length: {{ comparison.length }}</li>
<li title="Number of points in the baseline project">Baseline Points: {{ comparison.baselineLength }} (Unique: {{ comparison.baselineUniqueLength }})</li>
<li title="Number of points in the monitor project">Monitor Points: {{ comparison.monitorLength }} (Unique: {{ comparison.monitorUniqueLength }})</li>
</ul>
<p class="mt-3" title="Date and time when the comparison was last performed">Computation timestamp: {{ new Date(comparison.tstamp).toLocaleString() }}</p>
</v-col>
<v-col cols="12" md="6">
<h3>Error Ellipse</h3>
<svg width="300" height="300" style="border: 1px solid #ccc;">
<g :transform="`translate(150, 150) scale(${ellipseScale})`">
<line x1="0" y1="-150" x2="0" y2="150" stroke="lightgray" stroke-dasharray="5,5"/>
<line x1="-150" y1="0" x2="150" y2="0" stroke="lightgray" stroke-dasharray="5,5"/>
<ellipse
:rx="Math.sqrt(comparison.eigenvalues[0])"
:ry="Math.sqrt(comparison.eigenvalues[1])"
:transform="`rotate(${ellipseAngle})`"
fill="none"
stroke="blue"
stroke-width="2"
/>
<line
:x1="0"
:y1="0"
:x2="Math.sqrt(comparison.eigenvalues[0]) * Math.cos(ellipseRad)"
:y2="Math.sqrt(comparison.eigenvalues[0]) * Math.sin(ellipseRad)"
stroke="red"
stroke-width="2"
arrow-end="classic-wide-long"
/>
<line
:x1="0"
:y1="0"
:x2="Math.sqrt(comparison.eigenvalues[1]) * Math.cos(ellipseRad + Math.PI / 2)"
:y2="Math.sqrt(comparison.eigenvalues[1]) * Math.sin(ellipseRad + Math.PI / 2)"
stroke="green"
stroke-width="2"
arrow-end="classic-wide-long"
/>
</g>
</svg>
<p class="text-caption">Ellipse scaled for visibility (factor: {{ ellipseScale.toFixed(1) }}). Axes represent sqrt(eigenvalues).</p>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: "DougalGroupComparisonSummary",
props: {
baseline: { type: Object, required: true },
monitor: { type: Object, required: true },
comparison: { type: Object, required: true }
},
data () {
return {
};
},
computed: {
ellipseAngle () {
if (!this.comparison) return 0;
const ev = this.comparison.eigenvectors[0];
return Math.atan2(ev[1], ev[0]) * 180 / Math.PI;
},
ellipseRad () {
return this.ellipseAngle * Math.PI / 180;
},
ellipseRx () {
if (!this.comparison) return 0;
return Math.sqrt(this.comparison.eigenvalues[0]) * this.ellipseScale;
},
ellipseRy () {
if (!this.comparison) return 0;
return Math.sqrt(this.comparison.eigenvalues[1]) * this.ellipseScale;
},
ellipseScale () {
if (!this.comparison) return 1;
const maxSigma = Math.max(
Math.sqrt(this.comparison.eigenvalues[0]),
Math.sqrt(this.comparison.eigenvalues[1])
);
const maxMu = Math.max(
Math.abs(this.comparison['μ'][0]),
Math.abs(this.comparison['μ'][1])
);
//const maxExtent = maxMu + 3 * maxSigma;
const maxExtent = 20;
return 100 / maxExtent; // Adjust scale to fit within ~200 pixels diameter
},
ellipseArea () {
if (!this.comparison) return 0;
const a = Math.sqrt(this.comparison.eigenvalues[0]);
const b = Math.sqrt(this.comparison.eigenvalues[1]);
return Math.PI * a * b;
},
semiMajorAxis () {
if (!this.comparison) return 0;
return Math.max(
Math.sqrt(this.comparison.eigenvalues[0]),
Math.sqrt(this.comparison.eigenvalues[1])
);
},
semiMinorAxis () {
if (!this.comparison) return 0;
return Math.min(
Math.sqrt(this.comparison.eigenvalues[0]),
Math.sqrt(this.comparison.eigenvalues[1])
);
},
meanX () {
return this.comparison ? this.comparison['μ'][0] : 0;
},
meanY () {
return this.comparison ? this.comparison['μ'][1] : 0;
},
ellipseViewBox () {
return '-150 -150 300 300';
},
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
<template>
<v-card class="ma-1">
<v-card-title>Group Repeatability Summary</v-card-title>
<v-card-text>
<p>Error ellipse area for each baseline-monitor pair. Lower values indicate better repeatability. Colors range from green (best) to red (worst).</p>
<v-simple-table dense>
<thead>
<tr>
<th>Baseline \ Monitor</th>
<th v-for="project in projects" :key="project.pid">{{ project.pid }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(baselineProject, rowIndex) in projects" :key="baselineProject.pid">
<td>{{ baselineProject.pid }}</td>
<td v-for="(monitorProject, colIndex) in projects" :key="monitorProject.pid">
<v-tooltip v-if="colIndex > rowIndex" top>
<template v-slot:activator="{ on, attrs }">
<div
:style="{ backgroundColor: getEllipseAreaColor(baselineProject.pid, monitorProject.pid), color: 'white', textAlign: 'center', padding: '4px' }"
v-bind="attrs"
v-on="on"
@click="emitInput(baselineProject, monitorProject)"
>
{{ formatEllipseArea(baselineProject.pid, monitorProject.pid) }}
</div>
</template>
<span v-if="getComp(baselineProject.pid, monitorProject.pid)">
<div>σ_i: {{ getComp(baselineProject.pid, monitorProject.pid).meta['σ'][0].toFixed(2) }} m</div>
<div>σ_j: {{ getComp(baselineProject.pid, monitorProject.pid).meta['σ'][1].toFixed(2) }} m</div>
<div>Anisotropy: {{ getComp(baselineProject.pid, monitorProject.pid).meta.anisotropy.toFixed(0) }}</div>
<div>Ellipse Area: {{ getEllipseArea(baselineProject.pid, monitorProject.pid).toFixed(2) }} </div>
<div>Primary Direction: {{ formatPrimaryDirection(getComp(baselineProject.pid, monitorProject.pid)) }}°</div>
</span>
</v-tooltip>
</td>
</tr>
</tbody>
</v-simple-table>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'DougalGroupRepeatabilitySummary',
props: {
comparisons: {
type: Array,
required: true
},
projects: {
type: Array,
required: true
}
},
data () {
return {
};
},
computed: {
compMap () {
return new Map(this.comparisons.map(c => [`${c.baseline_pid}-${c.monitor_pid}`, c]));
},
minEllipseArea () {
if (!this.comparisons.length) return 0;
return Math.min(...this.comparisons.map(c => {
const a = Math.sqrt(c.meta.eigenvalues[0]);
const b = Math.sqrt(c.meta.eigenvalues[1]);
return Math.PI * a * b;
}));
},
maxEllipseArea () {
if (!this.comparisons.length) return 0;
return Math.max(...this.comparisons.map(c => {
const a = Math.sqrt(c.meta.eigenvalues[0]);
const b = Math.sqrt(c.meta.eigenvalues[1]);
return Math.PI * a * b;
}));
}
},
methods: {
getComp (basePid, monPid) {
return this.compMap.get(`${basePid}-${monPid}`);
},
getEllipseArea (basePid, monPid) {
const comp = this.getComp(basePid, monPid);
if (!comp) return null;
const a = Math.sqrt(comp.meta.eigenvalues[0]);
const b = Math.sqrt(comp.meta.eigenvalues[1]);
return Math.PI * a * b;
},
formatEllipseArea (basePid, monPid) {
const val = this.getEllipseArea(basePid, monPid);
return val !== null ? val.toFixed(1) : '';
},
getEllipseAreaColor (basePid, monPid) {
const val = this.getEllipseArea(basePid, monPid);
if (val === null) return '';
const ratio = (val - this.minEllipseArea) / (this.maxEllipseArea - this.minEllipseArea);
const hue = (1 - ratio) * 120;
return `hsl(${hue}, 70%, 70%)`;
},
formatPrimaryDirection (comp) {
if (!comp) return '';
return (comp.meta.primaryDirection * 180 / Math.PI).toFixed(1);
},
emitInput (baselineProject, monitorProject) {
if (this.getComp(baselineProject.pid, monitorProject.pid)) {
this.$emit('input', baselineProject, monitorProject);
}
}
}
}
</script>

View File

@@ -2,6 +2,7 @@
<v-dialog
v-model="dialog"
max-width="500"
scrollable
style="z-index:2020;"
>
<template v-slot:activator="{ on, attrs }">
@@ -14,6 +15,8 @@
</template>
<v-card>
<v-window v-model="page">
<v-window-item value="support">
<v-card-title class="headline">
Dougal user support
</v-card-title>
@@ -22,7 +25,44 @@
<p>You can get help or report a problem by sending an email to <a :href="`mailto:${email}`">{{email}}</a>. Please include as much information as possible about your problem or questionscreenshots are often a good idea, and data files may also be attached.</p>
<p>When you write to the above address a ticket will be automatically created in the project's issue tracking system.</p>
<v-alert dense type="info" border="left" outlined>
<div class="text-body-2">
You are using Dougal version:
<ul>
<li><code>{{clientVersion}}</code> (client)</li>
<li><code>{{serverVersion}}</code> (server)</li>
</ul>
</div>
</v-alert>
</v-card-text>
</v-window-item>
<v-window-item value="changelog">
<v-card-title class="headline">
Dougal release notes
</v-card-title>
<v-card-text>
<v-carousel v-model="releaseShown"
:continuous="false"
:cycle="false"
:show-arrows="true"
:hide-delimiters="true"
>
<v-carousel-item v-for="release in releaseHistory">
<pre>{{release}}</pre>
</v-carousel-item>
</v-carousel>
</v-card-text>
</v-window-item>
<v-window-item value="serverinfo">
<dougal-server-status :status="serverStatus"></dougal-server-status>
</v-window-item>
</v-window>
<v-divider></v-divider>
@@ -33,8 +73,7 @@
text
:href="`mailto:${email}?Subject=Question`"
>
<v-icon class="d-lg-none">mdi-help-circle</v-icon>
<span class="d-none d-lg-inline">Ask a question</span>
<v-icon title="Ask a question">mdi-help-circle</v-icon>
</v-btn>
<v-btn
@@ -42,10 +81,10 @@
text
href="mailto:dougal-support@aaltronav.eu?Subject=Bug report"
>
<v-icon class="d-lg-none">mdi-bug</v-icon>
<span class="d-none d-lg-inline">Report a bug</span>
<v-icon title="Report a bug">mdi-bug</v-icon>
</v-btn>
<!---
<v-btn
color="info"
text
@@ -54,6 +93,37 @@
>
<v-icon>mdi-rss</v-icon>
</v-btn>
--->
<v-btn
color="info"
text
title="View support info"
:input-value="page == 'support'"
@click="page = 'support'"
>
<v-icon>mdi-account-question</v-icon>
</v-btn>
<v-btn v-if="versionHistory"
color="info"
text
title="View release notes"
:input-value="page == 'changelog'"
@click="page = 'changelog'"
>
<v-icon>mdi-history</v-icon>
</v-btn>
<v-btn v-if="serverStatus"
color="info"
text
title="View server status"
:input-value="page == 'serverinfo'"
@click="page = 'serverinfo'"
>
<v-icon>mdi-server-network</v-icon>
</v-btn>
<v-spacer></v-spacer>
@@ -75,15 +145,111 @@
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalServerStatus from './server-status';
export default {
name: 'DougalHelpDialog',
components: {
DougalServerStatus
},
data () {
return {
dialog: false,
email: "dougal-support@aaltronav.eu",
feed: btoa(encodeURIComponent("https://gitlab.com/wgp/dougal/software.atom?feed_token=XSPpvsYEny8YmH75Nz5W"))
feed: btoa(encodeURIComponent("https://gitlab.com/wgp/dougal/software.atom?feed_token=XSPpvsYEny8YmH75Nz5W")),
serverStatus: null,
clientVersion: process.env.DOUGAL_FRONTEND_VERSION ?? "(unknown)",
serverVersion: null,
versionHistory: null,
releaseHistory: [],
releaseShown: null,
page: "support",
lastUpdate: 0,
updateInterval: 12000,
refreshTimer: null
};
},
computed: {
sinceUpdate () {
return this.lastUpdate
? (Date.now() - this.lastUpdate)
: +Infinity;
}
},
watch: {
dialog(newVal) {
if (newVal) {
this.startAutoRefresh();
} else {
this.stopAutoRefresh();
}
},
page(newVal) {
if (newVal === 'serverinfo' && this.dialog) {
this.getServerStatus(); // Immediate update when switching to serverinfo
this.startAutoRefresh();
} else {
this.stopAutoRefresh();
}
}
},
methods: {
async getServerVersion () {
if (!this.serverVersion) {
const version = await this.api(['/version', {}, null, {silent:true}]);
this.serverVersion = version?.tag ?? "(unknown)";
if (version) this.lastUpdate = Date.now();
}
if (!this.versionHistory) {
const history = await this.api(['/version/history?count=6', {}, null, {silent:true}]);
this.releaseHistory = history;
this.versionHistory = history?.[this.serverVersion.replace(/-.*$/, "")] ?? null;
}
},
async getServerStatus () {
const status = await this.api(['/diagnostics', {}, null, {silent: true}]);
if (status) {
this.serverStatus = status;
this.lastUpdate = Date.now();
}
},
startAutoRefresh() {
if (this.refreshTimer) return; // Prevent multiple timers
this.refreshTimer = setInterval(() => {
if (this.dialog && this.page === 'serverinfo') {
this.getServerStatus();
// Optionally refresh server version if needed
// this.getServerVersion();
}
}, this.updateInterval);
},
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
},
...mapActions(["api"])
},
async mounted () {
this.getServerVersion();
this.getServerStatus();
},
beforeDestroy() {
this.stopAutoRefresh(); // Clean up timer on component destruction
}
};

View File

@@ -1,8 +1,5 @@
<template>
<div class="line-status" v-if="sequences.length == 0">
<slot name="empty"></slot>
</div>
<div class="line-status" v-else-if="sequenceHref || plannedSequenceHref || pendingReshootHref">
<div class="line-status" v-if="sequenceHref || plannedSequenceHref || pendingReshootHref">
<router-link v-for="sequence in sequences" :key="sequence.sequence" v-if="sequenceHref"
class="sequence"
:class="sequence.status"
@@ -26,7 +23,7 @@
>
</router-link>
</div>
<div class="line-status" v-else>
<div class="line-status" v-else-if="sequences.length || plannedSequences.length || Object.keys(pendingReshoots).length">
<div v-for="sequence in sequences" :key="sequence.sequence"
class="sequence"
:class="sequence.status"
@@ -47,6 +44,9 @@
>
</div>
</div>
<div class="line-status" v-else>
<slot name="empty"></slot>
</div>
</template>
<style lang="stylus" scoped>

View File

@@ -4,15 +4,15 @@
<v-card-subtitle v-text="subtitle"></v-card-subtitle>
<v-card-text>
<v-tabs v-model="tab">
<v-tab>Paths</v-tab>
<v-tab>Globs</v-tab>
<v-tab v-if="pattern">Pattern</v-tab>
<v-tab v-if="lineNameInfo">Line info</v-tab>
<v-tab tab-value="paths">Paths</v-tab>
<v-tab tab-value="globs">Globs</v-tab>
<v-tab tab-value="pattern" v-if="pattern">Pattern</v-tab>
<v-tab tab-value="lineNameInfo" v-if="lineNameInfo">Line info</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item>
<v-tab-item value="paths">
<v-card flat>
<v-card-subtitle>
A list of directories which are searched for matching files.
@@ -56,7 +56,7 @@
</v-card>
</v-tab-item>
<v-tab-item>
<v-tab-item value="globs">
<v-card flat>
<v-card-subtitle>
A list of <a href="https://en.wikipedia.org/wiki/Glob_(programming)" target="_blank">glob patterns</a> expanding to match the files of interest. Note that Linux is case-sensitive.
@@ -93,7 +93,7 @@
</v-card>
</v-tab-item>
<v-tab-item v-if="pattern">
<v-tab-item value="pattern" v-if="pattern">
<v-card flat>
<v-card-subtitle>
Regular expression that describes the file format definition. Used to capture information such as line and sequence number, etc.
@@ -153,7 +153,7 @@
</v-card>
</v-tab-item>
<v-tab-item v-if="lineNameInfo">
<v-tab-item value="lineNameInfo">
<v-card flat>
<v-card-subtitle>
Line information that will be extracted from file names
@@ -165,14 +165,14 @@
label="Example file name"
hint="Enter the name of a representative file to make it easier to visualise your configuration"
persistent-hint
v-model="lineNameInfo.example"
v-model="lineNameInfo_.example"
></v-text-field>
<dougal-fixed-string-decoder
:multiline="true"
:text="lineNameInfo.example"
:fixed.sync="lineNameInfo.fixed"
:fields.sync="lineNameInfo.fields"
:text="lineNameInfo_.example"
:fixed.sync="lineNameInfo_.fixed"
:fields.sync="lineNameInfo_.fields"
></dougal-fixed-string-decoder>
</v-form>
@@ -195,6 +195,23 @@
@click="reset"
>Reset</v-btn>
-->
<v-btn
v-if="tab=='lineNameInfo'"
:disabled="!validLineNameInfo"
@click="copyLineNameInfo"
title="Copy this definition into the clipboard. It can then be pasted into other sections or configurations."
>
<v-icon left>mdi-content-copy</v-icon>
Copy
</v-btn>
<v-btn
v-if="tab=='lineNameInfo'"
@click="pasteLineNameInfo"
title="Paste a line info definition copied from elsewhere"
>
<v-icon left>mdi-content-paste</v-icon>
Paste
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="secondary"
@@ -253,6 +270,9 @@ export default {
},
computed: {
validLineNameInfo () {
return typeof this.lineNameInfo == 'object';
},
},
watch: {
@@ -285,6 +305,28 @@ export default {
methods: {
async copyLineNameInfo () {
await navigator.clipboard.writeText(JSON.stringify(this.lineNameInfo, null, 4));
this.showSnack(["Line name information copied to clipboard", "primary"]);
},
async pasteLineNameInfo () {
const text = await navigator.clipboard.readText();
try {
const data = JSON.parse(text);
if (["fixed", "fields", "example"].every( key => key in data )) {
this.$emit("update:lineNameInfo", data);
this.showSnack(["Line name information pasted from clipboard", "primary"]);
} else {
this.showSnack(["Clipboard contents are not valid line name information", "error"]);
}
} catch (err) {
if (err instanceof SyntaxError) {
this.showSnack(["Clipboard contents are not valid line name information", "error"]);
}
}
},
reset () {
this.globs_ = this.globs;
this.paths_ = this.paths;
@@ -302,6 +344,8 @@ export default {
this.$emit('close');
},
...mapActions(["showSnack"])
},
mounted () {

View File

@@ -519,17 +519,18 @@ export default {
methods: {
async getHead () {
console.log("getHead", this.value?.path);
if (this.value?.path) {
const url = `/files/${this.value.path}`;
const init = {
text: true,
headers: {
"Range": `bytes=0-${this.sampleSize}`
}
};
const head = await this.api([url, init]);
return head?.substring(0, head.lastIndexOf("\n")) || "";
const opts = {format: "text"};
const head = await this.api([url, init, null, opts]);
return typeof head === "string"
? head?.substring(0, head.lastIndexOf("\n")) || ""
: this.head ?? "";
}
return "";
},

View File

@@ -0,0 +1,213 @@
<template>
<v-card max-width="800" max-height="600" class="mx-auto" style="overflow-y: auto;">
<v-card-title class="headline">
Server status {{ status.hostname }}
</v-card-title>
<v-card-text>
<v-expansion-panels accordion>
<!-- System Info -->
<v-expansion-panel>
<v-expansion-panel-header>System Info</v-expansion-panel-header>
<v-expansion-panel-content>
<v-row>
<v-col cols="6">
<strong>Uptime:</strong> {{ formatUptime(status.uptime) }}
</v-col>
<v-col cols="6">
<strong>Load:</strong> {{ status.loadavg[0].toFixed(2) }} / {{ status.loadavg[1].toFixed(2) }} / {{ status.loadavg[2].toFixed(2) }}
<v-progress-linear
:value="loadAvgPercent"
:color="getLoadAvgColor(status.loadavg[0])"
height="6"
rounded
></v-progress-linear>
<div class="text-caption">
1-min Load: {{ status.loadavg[0].toFixed(2) }} ({{ loadAvgPercent.toFixed(1) }}% of max)
</div>
</v-col>
</v-row>
</v-expansion-panel-content>
</v-expansion-panel>
<!-- Memory -->
<v-expansion-panel>
<v-expansion-panel-header>Memory</v-expansion-panel-header>
<v-expansion-panel-content>
<v-progress-linear
:value="memoryUsedPercent"
:color="getProgressColor(memoryUsedPercent)"
height="10"
rounded
></v-progress-linear>
<div class="text-caption mt-2">
Used: {{ formatBytes(status.memory.total - status.memory.free) }} / Total: {{ formatBytes(status.memory.total) }} ({{ memoryUsedPercent.toFixed(1) }}%)
</div>
</v-expansion-panel-content>
</v-expansion-panel>
<!-- CPUs -->
<v-expansion-panel>
<v-expansion-panel-header>CPUs ({{ status.cpus.length }} cores)</v-expansion-panel-header>
<v-expansion-panel-content>
<v-row dense>
<v-col v-for="(cpu, index) in status.cpus" :key="index" cols="12" sm="6">
<v-card outlined class="pa-2">
<div class="text-caption">Core {{ index + 1 }}: {{ cpu.model }} @ {{ cpu.speed }} MHz</div>
<v-progress-linear
:value="cpuUsagePercent(cpu)"
:color="getProgressColor(cpuUsagePercent(cpu))"
height="8"
rounded
></v-progress-linear>
<div class="text-caption">
Usage: {{ cpuUsagePercent(cpu).toFixed(1) }}% (Idle: {{ cpuIdlePercent(cpu).toFixed(1) }}%)
</div>
</v-card>
</v-col>
</v-row>
</v-expansion-panel-content>
</v-expansion-panel>
<!-- Network Interfaces -->
<v-expansion-panel>
<v-expansion-panel-header>Network Interfaces</v-expansion-panel-header>
<v-expansion-panel-content>
<v-list dense>
<v-list-item v-for="(iface, name) in status.networkInterfaces" :key="name">
<v-list-item-content>
<v-list-item-title>{{ name }}</v-list-item-title>
<v-list-item-subtitle v-for="(addr, idx) in iface" :key="idx">
{{ addr.family }}: {{ addr.address }} (Netmask: {{ addr.netmask }})
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-expansion-panel-content>
</v-expansion-panel>
<!-- Storage -->
<v-expansion-panel>
<v-expansion-panel-header>Storage</v-expansion-panel-header>
<v-expansion-panel-content>
<!-- Root -->
<div class="mb-4">
<strong>Root (/):</strong>
<v-progress-linear
:value="status.storage.root.usedPercent"
:color="getProgressColor(status.storage.root.usedPercent)"
height="10"
rounded
></v-progress-linear>
<div class="text-caption">
Used: {{ formatBytes(status.storage.root.used) }} / Total: {{ formatBytes(status.storage.root.total) }} ({{ status.storage.root.usedPercent.toFixed(1) }}%)
</div>
</div>
<!-- Data subfolders -->
<div>
<strong>Data:</strong>
<v-expansion-panels flat>
<v-expansion-panel v-for="(folder, name) in status.storage.data" :key="name">
<v-expansion-panel-header disable-icon-rotate>{{ name }}</v-expansion-panel-header>
<v-expansion-panel-content>
<v-progress-linear
:value="folder.usedPercent"
:color="getProgressColor(folder.usedPercent)"
height="10"
rounded
></v-progress-linear>
<div class="text-caption">
Used: {{ formatBytes(folder.used) }} / Total: {{ formatBytes(folder.total) }} ({{ folder.usedPercent.toFixed(1) }}%)
</div>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-expansion-panel-content>
</v-expansion-panel>
<!-- Database -->
<v-expansion-panel>
<v-expansion-panel-header>Database</v-expansion-panel-header>
<v-expansion-panel-content>
<div class="mb-2">
<strong>Total Size:</strong> {{ formatBytes(status.database.size) }}
</div>
<v-list dense>
<v-list-item v-for="(project, name) in status.database.projects" :key="name">
<v-list-item-content>
<v-list-item-title>{{ name }}</v-list-item-title>
<v-progress-linear
:value="project.percent"
:color="getProgressColor(project.percent)"
height="8"
rounded
></v-progress-linear>
<v-list-item-subtitle>
Size: {{ formatBytes(project.size) }} ({{ project.percent.toFixed(2) }}%)
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: "DougalServerStatus",
props: {
status: {
type: Object,
required: true
}
},
computed: {
memoryUsedPercent() {
return ((this.status.memory.total - this.status.memory.free) / this.status.memory.total) * 100;
},
loadAvgPercent() {
const maxLoad = this.status.cpus.length * 4; // Assume 4x cores as max for scaling
return Math.min((this.status.loadavg[0] / maxLoad) * 100, 100); // Cap at 100%
}
},
methods: {
getProgressColor(value) {
if (value >= 80) return 'error'; // Red for 80100%
if (value >= 60) return 'warning'; // Yellow for 6080%
return 'success'; // Green for 060%
},
getLoadAvgColor(load) {
const coreCount = this.status.cpus.length;
if (load >= coreCount * 2) return 'error'; // Red for load ≥ 2x cores
if (load >= coreCount) return 'warning'; // Yellow for load ≥ 1x cores but < 2x
return 'success'; // Green for load < 1x cores
},
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
seconds %= 86400;
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
return `${days}d ${hours}h ${minutes}m`;
},
cpuUsagePercent(cpu) {
const total = cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
return ((total - cpu.times.idle) / total) * 100;
},
cpuIdlePercent(cpu) {
const total = cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
return (cpu.times.idle / total) * 100;
}
}
};
</script>

View File

@@ -0,0 +1 @@
../../../../server/lib/binary

View File

@@ -0,0 +1,150 @@
// src/lib/deck.gl/DougalBinaryLoader.js
import { LoaderObject } from '@loaders.gl/core';
import { DougalBinaryBundle } from '@dougal/binary';
async function cachedFetch(url, init, opts = {}) {
let res; // The response
let cache; // Potentially, a Cache API cache name
let isCached;
if (opts?.cache === true) {
opts.cache = { name: "dougal" };
} else if (typeof opts?.cache === "string") {
opts.cache = { name: opts.cache };
} else if (opts?.cache) {
if (!(opts.cache instanceof Object)) {
opts.cache = { name: "dougal" }
} else if (!(opts.cache.name)) {
opts.cache.name = "dougal";
}
}
if (opts?.cache && window.cache) {
cache = await caches.open(opts.cache.name);
res = await cache.match(url);
isCached = !!res;
}
if (!res) {
res = await fetch(url, init);
}
if (cache && !isCached) {
cache.put(url, res.clone());
}
return res;
}
const DougalBinaryLoader = {
name: 'DougalBinaryBundle Loader',
extensions: ['dbb'],
mimeTypes: ['application/vnd.aaltronav.dougal+octet-stream'],
parse: async (input, options) => {
let arrayBuffer;
if (typeof input === 'string') {
// Input is URL, fetch with caching
const response = await cachedFetch(input, options?.fetch, options);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
arrayBuffer = await response.arrayBuffer();
} else if (input instanceof ArrayBuffer) {
arrayBuffer = input;
} else {
throw new Error('Invalid input: Expected URL string or ArrayBuffer');
}
const bundle = DougalBinaryBundle.clone(arrayBuffer);
// Calculate total points
const totalCount = bundle.chunks().reduce((acc, chunk) => acc + chunk.jCount, 0);
// Prepare positions (Float32Array: [lon1, lat1, lon2, lat2, ...])
const positions = new Float32Array(totalCount * 2);
// Extract udv (assume constant across chunks)
const udv = bundle.chunks()[0].udv;
// Prepare values as an array of TypedArrays
const ΔelemCount = bundle.chunks()[0].ΔelemCount;
const elemCount = bundle.chunks()[0].elemCount;
const values = new Array(ΔelemCount + elemCount + 2);
// Initialize values arrays with correct types
if (udv == 0) {
for (let k = 0; k < values.length; k++) {
values[k] = new (k === 0 ? Uint16Array : k === 1 ? Uint32Array : Uint8Array)(totalCount);
}
} else if (udv == 1) {
for (let k = 0; k < values.length; k++) {
values[k] = new (k === 0 ? Uint16Array : k === 1 ? Uint32Array : k === 2 ? Uint8Array : Uint16Array)(totalCount);
}
} else if (udv == 2) {
for (let k = 0; k < values.length; k++) {
values[k] = new (k === 0 ? Uint16Array : k === 1 ? Uint32Array : k === 2 ? BigUint64Array : Float32Array)(totalCount);
}
} else if (udv == 4) {
for (let k = 0; k < values.length; k++) {
values[k] = new (k === 0 ? Uint16Array : k === 1 ? Uint32Array : k === 2 ? Uint16Array : Float32Array)(totalCount);
}
} else {
throw new Error(`Invalid udv: Expected 0, 1, 2, or 4; found ${udv}`);
}
let offset = 0;
for (const chunk of bundle.chunks()) {
const λarray = chunk.elem(0);
const φarray = chunk.elem(1);
for (let i = 0; i < λarray.length; i++) {
positions[offset * 2 + i * 2] = λarray[i];
positions[offset * 2 + i * 2 + 1] = φarray[i];
}
values[0].set(new Uint16Array(chunk.jCount).fill(chunk.i), offset);
values[1].set(Uint32Array.from({ length: chunk.jCount }, (_, i) => chunk.j0 + i * chunk.Δj), offset);
for (let j = 0; j < ΔelemCount; j++) {
values[2 + j].set(chunk.Δelem(j), offset);
}
for (let j = 2; j < elemCount; j++) {
values[2 + ΔelemCount + j - 2].set(chunk.elem(j), offset);
}
offset += chunk.jCount;
}
console.log(`Parsed ${totalCount} points, ${values.length} value arrays, udv = ${udv}`);
const attributes = {
getPosition: {
value: positions,
type: 'float32',
size: 2
},
udv
};
values.forEach((valArray, k) => {
let value = valArray;
if (valArray instanceof BigUint64Array) {
value = Float64Array.from(valArray, v => Number(v));
}
attributes[`value${k}`] = {
value,
type: value instanceof Float64Array ? 'float64' :
value instanceof Uint16Array ? 'uint16' :
value instanceof Uint32Array ? 'uint32' : 'float32',
size: 1
};
});
return {
length: totalCount,
attributes
};
},
options: {} // Optional: Add custom options if needed
};
export default DougalBinaryLoader;

View File

@@ -0,0 +1,144 @@
// Ref.: https://deck.gl/docs/developer-guide/custom-layers/composite-layers
import { CompositeLayer } from '@deck.gl/core';
import { GeoJsonLayer, ColumnLayer } from '@deck.gl/layers';
class DougalEventsLayer extends CompositeLayer {
static layerName = "DougalEventsLayer";
static defaultProps = {
columnsZoom: 11, // Threshold zoom level for switching layers
jitter: 0, // Add a small amount of jitter so that columns do not overlap.
// GeoJsonLayer props
getLineColor: [127, 65, 90],
getFillColor: [127, 65, 90],
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
lineWidthMinPixels: 2,
// ColumnLayer props
getPosition: { type: 'accessor', value: d => d.geometry.coordinates },
getElevation: { type: 'accessor', value: d => Math.min(Math.max(d.properties.remarks?.length || 10, 10), 200) },
diskResolution: 20,
radius: 5,
radiusUnits: "pixels",
radiusScale: 1,
elevationScale: 1,
filled: true,
stroked: false,
extruded: true,
wireframe: false,
material: true,
getFillColor: [255, 0, 0, 200],
getLineColor: [255, 0, 0, 200],
getLineWidth: 2,
pickable: true
}
constructor(props) {
super(props);
this.uid = "el-" + Math.random().toString().slice(2);
// Initialize state with current zoom
this.state = {
zoom: this.context?.viewport?.zoom || 0
};
}
shouldUpdateState({ changeFlags }) {
// Always update if viewport changed (including zoom)
if (changeFlags.viewportChanged) {
return true;
}
return super.shouldUpdateState({ changeFlags });
}
updateState({ props, oldProps, context, changeFlags }) {
// Check if zoom has changed
const newZoom = context.viewport?.zoom || 0;
if (newZoom !== this.state.zoom) {
this.setState({ zoom: newZoom });
this.setNeedsRedraw(); // Trigger re-render of sublayers
console.log(`Zoom changed to ${newZoom}, triggering redraw`);
}
}
getPickingInfo({ info, mode, sourceLayer }) {
if (info.index >= 0) {
info.object = {
...info.object // Merge default picking info (GeoJSON feature or ColumnLayer object)
};
if (sourceLayer) {
info.object.type = sourceLayer.constructor.layerName;
}
//console.log(`Picked ${info.object.type}, index ${info.index}`);
}
return info;
}
renderLayers() {
const { zoom } = this.state;
const sublayers = [];
if (zoom >= this.props.columnsZoom) {
// Render ColumnLayer at high zoom
const data = Array.isArray(this.props.data) ? this.props.data : this.props.data.features || [];
const positionFn = this.props.jitter
? (d, info) => {
let pos;
if (typeof this.props.getPosition == 'function') {
pos = this.props.getPosition(d, info);
} else {
pos = this.props.getPosition;
}
return pos.map( i => i + (Math.random() - 0.5) * this.props.jitter )
}
: this.props.getPosition;
sublayers.push(
new ColumnLayer(this.getSubLayerProps({
id: `${this.uid}-column`,
data,
visible: this.props.visible,
getPosition: positionFn,
getElevation: this.props.getElevation,
diskResolution: this.props.diskResolution,
radius: this.props.radius,
radiusUnits: this.props.radiusUnits,
radiusScale: this.props.radiusScale,
elevationScale: this.props.elevationScale,
filled: this.props.filled,
stroked: this.props.stroked,
extruded: this.props.extruded,
wireframe: this.props.wireframe,
material: this.props.material,
getFillColor: this.props.getFillColor,
getLineColor: this.props.getLineColor,
getLineWidth: this.props.getLineWidth,
pickable: this.props.pickable
}))
);
} else {
// Render GeoJsonLayer at low zoom
sublayers.push(
new GeoJsonLayer(this.getSubLayerProps({
id: `${this.uid}-geojson`,
data: this.props.data,
visible: this.props.visible,
getLineColor: this.props.getLineColor,
getFillColor: this.props.getFillColor,
getPointRadius: this.props.getPointRadius,
radiusUnits: this.props.radiusUnits,
pointRadiusMinPixels: this.props.pointRadiusMinPixels,
lineWidthMinPixels: this.props.lineWidthMinPixels,
pickable: this.props.pickable
}))
);
}
console.log(`Rendering ${sublayers.length} sublayer(s) at zoom ${zoom}`);
return sublayers;
}
}
export default DougalEventsLayer;

View File

@@ -0,0 +1,108 @@
// Ref.: https://deck.gl/docs/developer-guide/custom-layers/layer-lifecycle
import { ScatterplotLayer } from '@deck.gl/layers';
class DougalSequenceLayer extends ScatterplotLayer {
static layerName = "DougalSequenceLayer";
static defaultProps = {
...ScatterplotLayer.defaultProps,
valueIndex: 0,
radiusUnits: "pixels",
radiusScale: 1,
lineWidthUnits: "pixels",
lineWidthScale: 1,
stroked: false,
filled: true,
radiusMinPixels: 1,
radiusMaxPixels: 50,
lineWidthMinPixels: 1,
lineWidthMaxPixels: 50,
getPosition: { type: 'accessor', value: d => d.positions },
getRadius: 5,
getFillColor: [255, 0, 0, 200],
getLineColor: [255, 0, 0, 200],
getLineWidth: 2,
pickable: true
}
constructor(props) {
super(props);
}
initializeState(context) {
super.initializeState(context);
}
getPickingInfo({ info, mode }) {
const index = info.index;
if (index >= 0) {
const d = this.props.data.attributes;
if (d) {
if (d.udv == 0) {
info.object = {
udv: d.udv,
i: d.value0.value[index],
j: d.value1.value[index],
ntba: d.value2.value[index] & 0x01,
sailline_ntba: d.value2.value[index] & 0x02
};
} else if (d.udv == 1) {
info.object = {
udv: d.udv,
i: d.value0.value[index],
j: d.value1.value[index],
sailline: d.value3.value[index],
ntba: d.value2.value[index] & 0x01 ? true : false,
sailline_ntba: d.value2.value[index] & 0x02 ? true : false
};
} else if (d.udv == 2) {
info.object = {
udv: d.udv,
i: d.value0.value[index],
j: d.value1.value[index],
ts: Number(d.value2.value[index]),
εi: d.value3.value[index] / 100,
εj: d.value4.value[index] / 100,
delta: d.value5.value[index] / 10,
delta_σ: d.value6.value[index] / 10,
delta_R: d.value7.value[index] / 10,
press: d.value8.value[index],
press_σ: d.value9.value[index],
press_R: d.value10.value[index],
depth: d.value11.value[index] / 10,
depth_σ: d.value12.value[index] / 10,
depth_R: d.value13.value[index] / 10,
fill: d.value14.value[index],
fill_σ: d.value15.value[index],
fill_R: d.value16.value[index],
delay: d.value17.value[index] / 10,
delay_σ: d.value18.value[index] / 10,
delay_R: d.value19.value[index] / 10,
nofire: d.value20.value[index] >> 4,
autofire: d.value20.value[index] & 0xf
};
} else if (d.udv == 3) {
info.object = {
udv: d.udv,
i: d.value0.value[index],
j: d.value1.value[index],
ts: Number(d.value2.value[index]),
εi: d.value3.value[index] / 100,
εj: d.value4.value[index] / 100,
co_i: d.value5.value[index] / 100,
co_j: d.value6.value[index] / 100,
}
} else {
console.warn(`Unknown udv value ${d.udv}. No picking info`);
info.object = {};
}
console.log(`Picked sequence ${info.object.i}, point ${info.object.j}, udv ${info.object.udv}`);
} else {
console.log(`No data found index = ${index}`);
}
}
return info;
}
}
export default DougalSequenceLayer;

View File

@@ -0,0 +1,8 @@
import DougalSequenceLayer from './DougalSequenceLayer'
import DougalEventsLayer from './DougalEventsLayer'
export {
DougalSequenceLayer,
DougalEventsLayer
};

View File

@@ -0,0 +1,47 @@
function duration_to_ms(v) {
if (v instanceof Object) {
return (
(v.days || 0) * 86400000 +
(v.hours || 0) * 3600000 +
(v.minutes || 0) * 60000 +
(v.seconds || 0) * 1000 +
(v.milliseconds || 0)
);
} else {
return {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0
}
}
}
function ms_to_duration(v) {
const days = Math.floor(v / 86400000);
v %= 86400000;
const hours = Math.floor(v / 3600000);
v %= 3600000;
const minutes = Math.floor(v / 60000);
v %= 60000;
const seconds = Math.floor(v / 1000);
const milliseconds = v % 1000;
return { days, hours, minutes, seconds, milliseconds };
}
function normalise_duration (v) {
return ms_to_duration(duration_to_ms(v));
}
function add_durations(a, b) {
return ms_to_duration(duration_to_ms(a) + duration_to_ms(b));
}
export {
duration_to_ms,
ms_to_duration,
normalise_duration,
add_durations
}

View File

@@ -4,7 +4,7 @@ import router from './router'
import store from './store'
import vuetify from './plugins/vuetify'
import vueDebounce from 'vue-debounce'
import { mapMutations } from 'vuex';
import { mapMutations, mapActions } from 'vuex';
import { markdown, markdownInline } from './lib/markdown';
import { geometryAsString } from './lib/utils';
import { mapGetters } from 'vuex';
@@ -46,6 +46,12 @@ new Vue({
methods: {
async sleep (ms = 0) {
return await new Promise( (resolve) => {
setTimeout( resolve, ms );
});
},
markdown (value) {
return markdown(value);
},
@@ -56,12 +62,17 @@ new Vue({
showSnack(text, colour = "primary") {
console.log("showSnack", text, colour);
this.snackColour = colour;
this.snackText = text;
this.snack = true;
this.$store.dispatch("showSnack", [text, colour]);
},
sendJwt () {
if (this.jwt) {
this.ws.send(JSON.stringify({ jwt: this.jwt }));
}
},
initWs () {
if (this.ws) {
console.log("WebSocket initWs already called");
return;
@@ -71,11 +82,12 @@ new Vue({
this.ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
this.setServerEvent(msg);
this.processServerEvent(msg);
});
this.ws.addEventListener("open", (ev) => {
console.log("WebSocket connection open", ev);
this.sendJwt()
this.setServerConnectionState(true);
});
@@ -101,14 +113,13 @@ new Vue({
}
this.wsCredentialsCheckTimer = setInterval( () => {
this.ws.send(JSON.stringify({
jwt: this.jwt
}));
this.sendJwt();
}, this.wsCredentialsCheckInterval);
},
...mapMutations(['setServerEvent', 'setServerConnectionState'])
...mapMutations(['setServerConnectionState']),
...mapActions(['processServerEvent'])
},

View File

@@ -20,6 +20,9 @@ import ProjectSettings from '../views/ProjectSettings.vue'
import Users from '../views/Users.vue'
import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
import DougalAppBarExtensionProjectList from '../components/app-bar-extension-project-list'
import GroupList from '../views/GroupList.vue'
import Group from '../views/Group.vue'
Vue.use(VueRouter)
@@ -155,6 +158,7 @@ Vue.use(VueRouter)
component: SequenceList
},
{
name: "shotlog",
path: "sequences/:sequence",
component: SequenceSummary
},
@@ -196,7 +200,57 @@ Vue.use(VueRouter)
component: ProjectSettings
}
]
},
{
pathToRegexpOptions: { strict: true },
path: "/groups",
redirect: "/groups/"
},
{
pathToRegexpOptions: { strict: true },
path: "/groups/",
component: GroupList,
meta: {
breadcrumbs: [
{ text: "Comparisons", href: "/groups", disabled: true }
],
appBarExtension: {
// component: DougalAppBarExtensionProjectList
}
}
},
{
pathToRegexpOptions: { strict: true },
path: "/groups/:group",
redirect: "/groups/:group/"
},
{
pathToRegexpOptions: { strict: true },
path: "/groups/:group/",
name: "Group",
component: Group,
meta: {
breadcrumbs: [
{ text: "Comparisons", href: "/groups" },
{ text: (ctx) => ctx.$route.params.group }
/*
{
text: (ctx) => ctx.$store.state.project.projectName || "…",
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`,
title: (ctx) => Object.entries(ctx.$store.getters.projectConfiguration?.organisations ?? {}).map( ([org, ops]) => `* ${org}: ${Object.entries(ops).filter( ([k, v]) => v ).map( ([k, v]) => k ).join(", ")}`).join("\n"),
organisations: (ctx) => ctx.$store.getters.projectConfiguration?.organisations ?? {}
}
*/
],
/*
appBarExtension: {
component: DougalAppBarExtensionGroup
}
*/
},
children: [
]
},
]
const router = new VueRouter({

View File

@@ -1,5 +1,27 @@
const ConcurrencyLimiter = require('@dougal/concurrency');
/** Make an API request
*
* @a resource {String} is the target URL
* @a init {Object} are the Fetch options
* @a cb {Function} is a callback function: (res, err) => {}
* @a opts {Object} are other optional parameters:
* opts.silent {Boolean} controls whether snack messages are shown on failure
* opts.cache {Object} controls whether Cache API is used
* opts.cache.name {String} is the name of the cache to use. Defaults to "dougal"
*
* If Cache API is used, this function looks for a matching request in the cache
* first, and returns it if found. If not found, it makes the request over the API
* and then stores it in the cache.
*
* `opts.cache` may also be `true` (defaults to using the "dougal" cache),
* a cache name (equivalent to {name: "…"}) or even an empty object (equivalent
* to `true`).
*/
async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb, opts = {}]) {
const limiter = api.limiter || (api.limiter = new ConcurrencyLimiter(state.maxConcurrent));
try {
commit("queueRequest");
if (init && init.hasOwnProperty("body")) {
@@ -15,22 +37,89 @@ async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb
}
// We also send Authorization: Bearer …
if (getters.jwt) {
init.credentials = "include";
init.headers["Authorization"] = "Bearer "+getters.jwt;
}
if (typeof init.body != "string") {
init.body = JSON.stringify(init.body);
}
const url = /^https?:\/\//i.test(resource) ? resource : (state.apiUrl + resource);
const res = await fetch(url, init);
if (typeof cb === 'function') {
await cb(null, res);
let res; // The response
let cache; // Potentially, a Cache API cache name
let isCached;
if (opts?.cache === true) {
opts.cache = { name: "dougal" };
} else if (typeof opts?.cache === "string") {
opts.cache = { name: opts.cache };
} else if (opts?.cache) {
if (!(opts.cache instanceof Object)) {
opts.cache = { name: "dougal" }
} else if (!(opts.cache.name)) {
opts.cache.name = "dougal";
}
}
if (opts?.cache && window.caches) {
cache = await caches.open(opts.cache.name);
res = await cache.match(url);
isCached = !!res;
}
if (!res) {
res = await limiter.enqueue(async () => await fetch(url, init));
}
if (cache && !isCached && res.ok) { // Only cache successful responses
cache.put(url, res.clone());
}
if (typeof cb === 'function') {
await cb(null, res.clone());
}
if (res.headers.has("x-dougal-server")) {
const header = res.headers.get("x-dougal-server")
const entries = header
.split(";")
.map(part => part.trim())
.filter(part => part.length > 0)
.map(part => {
const idx = part.indexOf('=');
if (idx === -1) {
return [part, true];
}
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
return [key, value];
});
state.serverInfo = entries.length ? Object.fromEntries(entries) : {};
if (state.serverInfo["remote-frontend"]) {
state.isGatewayReliable = ![ 502, 503, 504 ].includes(res.status);
} else {
state.isGatewayReliable = null;
}
}
if (res.ok) {
await dispatch('setCredentials');
if (!isCached) {
if (res.headers.has("x-jwt")) {
await dispatch('setCredentials', { response: res });
}
}
try {
return init.text ? (await res.text()) : (await res.json());
if (!res.bodyUsed) { // It may have been consumed by a callback
const validFormats = [ "arrayBuffer", "blob", "formData", "json", "text" ];
if (opts.format && validFormats.includes(opts.format)) {
return await res[opts.format]();
} else {
return await res.json();
}
}
} catch (err) {
if (err instanceof SyntaxError) {
if (Number(res.headers.get("Content-Length")) === 0) {

View File

@@ -1,6 +1,9 @@
const state = () => ({
apiUrl: "/api",
requestsCount: 0
requestsCount: 0,
maxConcurrent: 15,
serverInfo: {}, // Contents of the last received X-Dougal-Server HTTP header
isGatewayReliable: null, // True if we start seeing HTTP 502504 responses
});
export default state;

View File

@@ -17,6 +17,7 @@ async function refreshEvents ({commit, dispatch, state, rootState}, [modifiedAft
? `/project/${pid}/event/changes/${(new Date(modifiedAfter)).toISOString()}?unique=t`
: `/project/${pid}/event`;
const init = {
cache: "reload",
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
@@ -35,7 +36,7 @@ async function refreshEvents ({commit, dispatch, state, rootState}, [modifiedAft
/** Return a subset of events from state.events
*/
async function getEvents ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text, label}]) {
async function getEvents ({commit, dispatch, state}, [projectId, {sequence, date0, date1, sortBy, sortDesc, itemsPerPage, page, text, label, excludeLabels}]) {
let filteredEvents = [...state.events];
if (sortBy) {
@@ -113,6 +114,10 @@ async function getEvents ({commit, dispatch, state}, [projectId, {sequence, date
filteredEvents = filteredEvents.filter( event => event.labels?.includes(label) );
}
if (excludeLabels) {
filteredEvents = filteredEvents.filter( event => !excludeLabels?.some( label => event.labels?.includes(label) ) );
}
const count = filteredEvents.length;
if (itemsPerPage && itemsPerPage > 0) {

View File

@@ -11,6 +11,7 @@ async function refreshLabels ({commit, dispatch, state, rootState}) {
const pid = rootState.project.projectId;
const url = `/project/${pid}/label`;
const init = {
cache: "reload",
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);

View File

@@ -11,6 +11,7 @@ async function refreshLines ({commit, dispatch, state, rootState}) {
const pid = rootState.project.projectId;
const url = `/project/${pid}/line`;
const init = {
cache: "reload",
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);

View File

@@ -0,0 +1,83 @@
import debounce from 'lodash/debounce';
function registerHandler({ commit }, { table, handler }) {
commit('REGISTER_HANDLER', { table, handler });
}
function unregisterHandler({ commit }, { table, handler }) {
commit('UNREGISTER_HANDLER', { table, handler });
}
function processServerEvent({ commit, dispatch, state, rootState }, message) {
//console.log("processServerEvent", message);
// Error handling for invalid messages
if (!message) {
console.error("processServerEvent called without arguments");
return;
}
if (!message.channel) {
console.error("processServerEvent message missing channel");
return;
}
if (!message.payload) {
console.error("processServerEvent message missing payload");
return;
}
if (message.payload.operation == "INSERT") {
if (message.payload.new == null) {
console.error("Expected payload.new to be non-null");
return;
}
} else if (message.payload.operation == "UPDATE") {
if (message.payload.old == null || message.payload.new == null) {
console.error("Expected payload.old and paylaod.new to be non-null");
return;
}
} else if (message.payload.operation == "DELETE") {
if (message.payload.old == null) {
console.error("Expected payload.old to be non-null");
return;
}
} else {
console.warn(`Unrecognised operation: ${message.payload.operation}`);
}
const table = message.channel; // or message.payload?.table;
//console.log("table=", table);
if (!table || !state.handlers[table] || state.handlers[table].length === 0) {
return;
}
// Create a debounced runner per table if not exists
if (!state.debouncedRunners) {
state.debouncedRunners = {}; // Not reactive needed? Or use Vue.set
}
if (!state.debouncedRunners[table]) {
const config = {
wait: 300, // min silence in ms
maxWait: 1000, // max wait before force run, adjustable
trailing: true,
leading: false
};
state.debouncedRunners[table] = debounce((lastMessage) => {
const context = { commit, dispatch, state: rootState, rootState }; // Approximate action context
state.handlers[table].forEach(handler => {
try {
//console.log("Trying handler:", handler);
handler(context, lastMessage);
} catch (e) {
console.error(`Error in handler for table ${table}:`, e);
}
});
}, config.wait, { maxWait: config.maxWait });
}
// Call the debounced function with the current message
// Debounce will use the last call's argument if multiple
state.debouncedRunners[table](message);
}
export default { registerHandler, unregisterHandler, processServerEvent };

View File

@@ -11,4 +11,29 @@ function setServerConnectionState (state, isConnected) {
state.serverConnected = !!isConnected;
}
export default { setServerEvent, clearServerEvent, setServerConnectionState };
function REGISTER_HANDLER(state, { table, handler }) {
if (!state.handlers[table]) {
state.handlers[table] = [];
}
if (!state.handlers[table].includes(handler)) {
state.handlers[table].push(handler);
}
}
function UNREGISTER_HANDLER(state, { table, handler }) {
if (state.handlers[table]) {
const handlerIndex = state.handlers[table].findIndex(el => el === handler);
if (handlerIndex != -1) {
state.handlers[table].splice(handlerIndex, 1);
}
}
}
export default {
setServerEvent,
clearServerEvent,
setServerConnectionState,
REGISTER_HANDLER,
UNREGISTER_HANDLER
};

View File

@@ -1,6 +1,7 @@
const state = () => ({
serverEvent: null,
serverConnected: false
serverConnected: false,
handlers: {}, // table: array of functions (each fn receives { commit, dispatch, state, rootState, message })
});
export default state;

View File

@@ -11,6 +11,7 @@ async function refreshPlan ({commit, dispatch, state, rootState}) {
const pid = rootState.project.projectId;
const url = `/project/${pid}/plan`;
const init = {
cache: "reload",
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);

View File

@@ -1,18 +1,47 @@
function transform (item) {
item.ts0 = new Date(item.ts0);
item.ts1 = new Date(item.ts1);
return item;
const newItem = {...item}
newItem.ts0 = new Date(newItem.ts0);
newItem.ts1 = new Date(newItem.ts1);
return newItem;
}
// ATTENTION: This relies on the new planner endpoint
// as per issue #281.
function setRemarks (state, remarks) {
state.remarks = remarks;
}
function setSequence (state, sequence) {
state.sequences.push(Object.freeze(transform(sequence)));
}
function deleteSequence (state, sequence) {
const seq = transform(sequence)
const idx = state.sequences?.findIndex( s => Object.keys(seq).every( k => JSON.stringify(s[k]) == JSON.stringify(seq[k]) ));
if (idx != -1) {
state.sequences.splice(idx, 1)
}
}
function replaceSequence (state, [oldSequence, newSequence]) {
console.log("replaceSequence", oldSequence, newSequence);
const seq = transform(oldSequence)
const idx = state.sequences?.findIndex( s => Object.keys(seq).every( k => JSON.stringify(s[k]) == JSON.stringify(seq[k]) ));
console.log("idx", idx);
if (idx != -1) {
state.sequences.splice(idx, 1, transform(newSequence))
console.log("spliced in");
}
}
function setPlan (state, plan) {
// We don't need or want the planned sequences array to be reactive
state.sequences = Object.freeze(plan.sequences.map(transform));
state.remarks = plan.remarks;
state.sequences = [];
plan.sequences.forEach( sequence => setSequence(state, sequence) );
setRemarks(state, plan.remarks);
}
function setPlanLoading (state, abortController = new AbortController()) {
@@ -51,6 +80,10 @@ function abortPlanLoading (state) {
}
export default {
setRemarks,
setSequence,
deleteSequence,
replaceSequence,
setPlan,
setPlanLoading,
clearPlanLoading,

View File

@@ -1,5 +1,5 @@
const state = () => ({
sequences: Object.freeze([]),
sequences: [],
remarks: null,
loading: null,
timestamp: null,

View File

@@ -1,7 +1,13 @@
async function getProject ({commit, dispatch}, projectId) {
if (projectId == null) {
console.log(`Skipping call to getProject${projectId})`);
return;
}
const init = {
headers: {
cache: "reload",
"If-None-Match": "" // Ensure we get a fresh response
}
};

View File

@@ -1,25 +1,55 @@
import * as d3a from 'd3-array';
import { duration_to_ms, ms_to_duration, normalise_duration, add_durations } from '@/lib/durations';
/** Fetch projects from server
*/
async function refreshProjects ({commit, dispatch, state, rootState}) {
async function getSummary (project) {
const url = `/project/${project.pid}/summary`;
const init = {};
const summary = await dispatch('api', [url, init, null, {silent:true}]);
if (summary) {
return {...project, ...summary};
} else {
return project;
}
}
if (state.loading) {
commit('abortProjectsLoading');
}
commit('setProjectsLoading');
const tstamp = new Date();
const pid = rootState.project.projectId;
const url = `/project`;
const init = {
cache: "reload",
signal: state.loading.signal
};
const res = await dispatch('api', [url, init, null, {silent:true}]);
if (res) {
commit('setProjects', res);
commit('setProjectsTimestamp');
let projects;
if (res.some( project => project.pid == null )) {
console.warn("At least one project found with no PID!");
projects = res.filter( project => project.pid != null );
} else {
projects = res;
}
commit('setProjects', projects); // First without summaries
commit('setProjectsTimestamp', tstamp);
projects = await Promise.all(projects.map( getSummary ));
commit('setProjects', projects); // Then with summaries
}
commit('clearProjectsLoading');
dispatch('prepareGroups');
}
/** Return a subset of projects from state.projects
@@ -117,4 +147,83 @@ async function getProjects ({commit, dispatch, state}, [{pid, name, schema, grou
return {projects: filteredProjects, count};
}
export default { refreshProjects, getProjects };
async function prepareGroups ({commit, dispatch, state, rootState}) {
const groups = {};
for (const project of state.projects) {
if (!project.prod_distance) {
// This project has no production data (either not started yet
// or production data has not been imported) so we skip it.
continue;
}
if (!project.prod_duration.days) {
project.prod_duration = normalise_duration(project.prod_duration);
}
for (const name of project.groups) {
if (!(name in groups)) {
groups[name] = {
group: name,
num_projects: 0,
lines: 0,
points: 0,
sequences: 0,
// Shots:
prime: 0,
other: 0,
ntba: 0,
prod_duration: {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0
},
prod_distance: 0,
shooting_rate: [],
projects: []
};
}
const group = groups[name];
group.num_projects++;
group.lines = Math.max(group.lines, project.lines); // In case preplots changed
group.points = Math.max(group.points, project.total); // Idem
group.sequences += project.seq_final;
group.prime += project.prime;
group.other += project.other;
//group.ntba += project.ntba;
group.prod_duration = add_durations(group.prod_duration, project.prod_duration);
group.prod_distance += project.prod_distance;
group.shooting_rate.push(project.shooting_rate);
group.projects.push(project);
}
}
const grouplist = [];
for (const group of Object.values(groups)) {
group.shooting_rate_mean = d3a.mean(group.shooting_rate);
group.shooting_rate_sd = d3a.deviation(group.shooting_rate);
delete group.shooting_rate;
grouplist.push(group);
}
commit('setGroups', grouplist);
}
async function getGroups({commit, dispatch, state, rootState}) {
if (!state.groups.length) {
await dispatch('refreshProjects');
}
return state.groups;
}
export default { refreshProjects, getProjects, prepareGroups, getGroups };

View File

@@ -3,7 +3,7 @@ function projects (state) {
return state.projects;
}
function projectGroups (state) {
function projectGroupNames (state) {
return [...new Set(state.projects.map(i => i.groups).flat())].sort();
}
@@ -15,4 +15,8 @@ function projectsLoading (state) {
return !!state.loading;
}
export default { projects, projectGroups, projectCount, projectsLoading };
function groups (state) {
return state.groups;
}
export default { projects, projectGroupNames, projectCount, projectsLoading, groups };

View File

@@ -39,10 +39,15 @@ function abortProjectsLoading (state) {
state.loading = null;
}
function setGroups (state, groups) {
state.groups = Object.freeze(groups);
}
export default {
setProjects,
setProjectsLoading,
clearProjectsLoading,
setProjectsTimestamp,
setProjectsETag
setProjectsETag,
setGroups
};

View File

@@ -1,5 +1,6 @@
const state = () => ({
projects: Object.freeze([]),
groups: Object.freeze([]),
loading: null,
timestamp: null,
etag: null,

View File

@@ -11,6 +11,7 @@ async function refreshSequences ({commit, dispatch, state, rootState}) {
const pid = rootState.project.projectId;
const url = `/project/${pid}/sequence?files=true`;
const init = {
cache: "reload",
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);

View File

@@ -1,7 +1,7 @@
import jwt_decode from 'jwt-decode';
import { User } from '@/lib/user';
async function login ({commit, dispatch}, loginRequest) {
async function login ({ commit, dispatch }, loginRequest) {
const url = "/login";
const init = {
method: "POST",
@@ -9,93 +9,86 @@ async function login ({commit, dispatch}, loginRequest) {
"Content-Type": "application/json"
},
body: loginRequest
};
const callback = async (err, res) => {
if (!err && res) {
const { token } = (await res.json());
await dispatch('setCredentials', {token});
}
const res = await dispatch('api', [url, init]);
if (res && res.ok) {
await dispatch('setCredentials', {force: true});
}
await dispatch('api', [url, init, callback]);
await dispatch('loadUserPreferences');
}
}
async function logout ({commit, dispatch}) {
commit('setCookie', null);
async function logout ({ commit, dispatch }) {
commit('setToken', 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, rootState}, {force, token} = {}) {
if (token || force || cookieChanged(state.cookie)) {
function setCredentials({ state, commit, getters, dispatch, rootState }, { force, token, response } = {}) {
try {
const cookie = browserCookie();
const decoded = (token ?? cookie) ? jwt_decode(token ?? cookie.split("=")[1]) : null;
commit('setCookie', (cookie ?? (token && ("JWT="+token))) || undefined);
commit('setUser', decoded ? new User(decoded, rootState.api.api) : null);
} catch (err) {
if (err.name == "InvalidTokenError") {
console.warn("Failed to decode", browserCookie());
} else {
console.error("setCredentials", err);
let tokenValue = token;
if (!tokenValue && response?.headers?.get('x-jwt')) {
tokenValue = response.headers.get('x-jwt');
}
if (!tokenValue) {
console.log('No JWT found in token or response');
return;
}
if (force || tokenValue !== getters.jwt) {
const decoded = jwt_decode(tokenValue);
commit('setToken', tokenValue);
commit('setUser', decoded ? new User(decoded, rootState.api.api) : null);
commit('setCookie', {name: "JWT", value: tokenValue, expires: (decoded.exp??0)*1000});
console.log('Credentials refreshed at', new Date().toISOString());
} else {
console.log('JWT unchanged, skipping update');
}
} catch (err) {
console.error('setCredentials error:', err.message, 'token:', token, 'response:', response?.headers?.get('x-jwt'));
if (err.name === 'InvalidTokenError') {
commit('setToken', null);
commit('setUser', null);
commit('clearCookie', "JWT")
}
}
dispatch('loadUserPreferences');
}
/**
* Save user preferences to localStorage and store.
*
* User preferences are identified by a key that gets
* prefixed with the user ID. The value can
* be anything that JSON.stringify can parse.
*/
function saveUserPreference ({state, commit}, [key, value]) {
function saveUserPreference({ state, commit }, [key, value]) {
const k = `${state.user?.id}.${key}`;
if (value !== undefined) {
localStorage.setItem(k, JSON.stringify(value));
const preferences = state.preferences;
preferences[key] = value;
const preferences = { ...state.preferences, [key]: value };
commit('setPreferences', preferences);
} else {
localStorage.removeItem(k);
const preferences = state.preferences;
const preferences = { ...state.preferences };
delete preferences[key];
commit('setPreferences', preferences);
}
}
async function loadUserPreferences ({state, commit}) {
// Get all keys which are of interest to us
async function loadUserPreferences({ state, commit }) {
const prefix = `${state.user?.id}`;
const keys = Object.keys(localStorage).filter( k => k.startsWith(prefix) );
// Build the preferences object
const keys = Object.keys(localStorage).filter(k => k.startsWith(prefix));
const preferences = {};
keys.map(str => {
keys.forEach(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,

View File

@@ -4,9 +4,7 @@ function user (state) {
}
function jwt (state) {
if (state.cookie?.startsWith("JWT=")) {
return state.cookie.substring(4);
}
return state.token;
}
function preferences (state) {

View File

@@ -1,6 +1,11 @@
function setCookie (state, cookie) {
state.cookie = cookie;
function setToken (state, token) {
state.token = token;
if (token) {
localStorage?.setItem("jwt", token);
} else {
localStorage?.removeItem("jwt");
}
}
function setUser (state, user) {
@@ -11,4 +16,18 @@ function setPreferences (state, preferences) {
state.preferences = preferences;
}
export default { setCookie, setUser, setPreferences };
function setCookie (state, opts = {}) {
const name = opts.name ?? "JWT";
const value = opts.value ?? "";
const expires = opts.expires ? (new Date(opts.expires)) : (new Date(0));
const path = opts.path ?? "/";
const sameSite = opts.sameSite ?? "Lax";
document.cookie = `${name}=${value};path=${path};SameSite=${sameSite};expires=${expires.toUTCString()}`;
}
function clearCookie (state, name) {
setCookie(state, {name});
}
export default { setToken, setUser, setPreferences, setCookie, clearCookie };

View File

@@ -1,5 +1,5 @@
const state = () => ({
cookie: null,
token: localStorage?.getItem("jwt") ?? null,
user: null,
preferences: {}
});

View File

@@ -91,7 +91,7 @@ export default {
},
async refresh () {
const text = await this.api([`/rss/?remote=${atob(this.$route.params.source)}`, {text:true}]);
const text = await this.api([`/rss/?remote=${atob(this.$route.params.source)}`, {format:"text"}]);
try {
this.feed = this.parse(text);
} catch (err) {

View File

@@ -0,0 +1,339 @@
<template>
<dougal-group-map v-if="mapView"
:baseline="baseline"
:monitor="monitor"
:monitors="monitors"
@input="mapView=$event"
></dougal-group-map>
<v-container fluid fill-height class="ma-0 pa-0" v-else>
<v-overlay :value="loading && !comparisons.length" absolute>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
</v-overlay>
<v-overlay :value="!loading && !groupFound" absolute opacity="0.8">
<v-row justify="center">
<v-alert
type="error"
>
Group not found
</v-alert>
</v-row>
<v-row justify="center">
<v-btn color="primary" @click="refreshProjects">Retry</v-btn>
</v-row>
</v-overlay>
<v-row no-gutters align="stretch" class="fill-height">
<v-col cols="12" v-if="groupFound">
<v-data-table class="ma-1"
:headers="projectHeaders"
:items="projects"
dense
>
<template v-slot:item.baseline="{item, value, index}">
<v-simple-checkbox v-if="index+1 < projects.length"
color="primary"
:value="baseline === item"
@input="setBaseline(item)"
></v-simple-checkbox>
</template>
<template v-slot:item.monitor="{item, value, index}">
<v-simple-checkbox v-if="index > 0 && !(index <= baselineIndex)"
color="primary"
:value="monitor === item"
@input="setMonitor(item)"
></v-simple-checkbox>
</template>
<template v-slot:item.pid="{item, value}">
<v-chip
label
small
outlined
:href="`/projects/${item.pid}`"
:color="!item.archived ? 'primary' : ''"
>{{ value }}</v-chip>
</template>
<template v-slot:item.fsp="{item, value}">
<span title="First production shot">{{value.tstamp.substr(0, 10)}}</span>
</template>
<template v-slot:item.lsp="{item, value}">
<span title="Last production shot">{{value.tstamp.substr(0, 10)}}</span>
</template>
<template v-slot:item.prod_duration="{item, value}">
<span v-if="value.days > 2" :title="`${value.days} d ${value.hours} h ${value.minutes} m ${(value.seconds + value.milliseconds/1000).toFixed(3)} s`">
{{ value.days }} d
</span>
<span v-else>
{{ value.days }} d {{ value.hours }} h {{ value.minutes }} m {{ (value.seconds + value.milliseconds/1000).toFixed(1) }} s
</span>
</template>
<template v-slot:item.prod_distance="{item, value}">
{{ (value/1000).toFixed(1) }} km
</template>
<template v-slot:footer.prepend>
<v-btn v-if="baseline && !mapView"
text
color="primary"
title="Switch to map view"
@click="mapView = true"
>View map</v-btn>
<v-btn v-if="comparison"
text
color="primary"
title="Back to summary"
@click="clearComparison"
>Back</v-btn>
</template>
</v-data-table>
<!-- BEGIN TEST -->
<dougal-group-comparison-summary v-if="comparison"
:baseline="baseline"
:monitor="monitor"
:comparison="comparison"
></dougal-group-comparison-summary>
<dougal-group-repeatability-summary v-else-if="comparisons.length"
:comparisons="comparisons"
:projects="projects"
@input="setComparison"
></dougal-group-repeatability-summary>
<!-- END TEST -->
</v-col>
<v-col cols="12" v-else>
<v-card>
<v-card-text>
Group does not exist.
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import AccessMixin from '@/mixins/access';
import DougalGroupRepeatabilitySummary from '@/components/groups/group-repeatability-summary.vue';
import DougalGroupComparisonSummary from '@/components/groups/group-comparison-summary';
import DougalGroupMap from '@/components/groups/group-map';
export default {
name: 'Group',
mixins: [
AccessMixin
],
components: {
DougalGroupRepeatabilitySummary,
DougalGroupComparisonSummary,
DougalGroupMap
},
data () {
return {
projectHeaders: [
{
value: "baseline",
text: "Baseline"
},
{
value: "monitor",
text: "Monitor"
},
{
value: "pid",
text: "ID"
},
{
value: "name",
text: "Name"
},
{
value: "fsp",
text: "Start"
},
{
value: "lsp",
text: "Finish"
},
{
value: "lines",
text: "Preplot lines"
},
{
value: "seq_final",
text: "Num. of sequences"
},
{
value: "prod_duration",
text: "Duration"
},
{
value: "prod_distance",
text: "Distance"
},
],
mapView: false,
baseline: null,
monitor: null,
comparisons: []
}
},
computed: {
groupName () {
return this.$route.params.group;
},
group () {
return this.groups.find( i => i.group === this.groupName );
},
groupFound () {
return !!(this.loading || this.group);
},
projects () {
return this.group?.projects.toSorted((a, b) => a.pid.localeCompare(b.pid));
},
baselineIndex () {
return this.projects.indexOf(this.baseline);
},
monitors () {
if (this.baseline && this.comparisons) {
return this.comparisons
.filter( i => i.baseline_pid == this.baseline.pid )
.map( i => this.projects.find( p => p.pid == i.monitor_pid ));
} else {
return null;
}
},
comparison () {
return this.comparisons.find( row =>
row.baseline_pid == this.baseline?.pid && row.monitor_pid == this.monitor?.pid
)?.meta;
},
...mapGetters(["loading", "groups"])
},
methods: {
setBaseline (project) {
if (project === this.baseline) {
this.baseline = null;
} else {
this.baseline = project;
if (this.monitor) {
if (this.projects.indexOf(this.monitor) <= this.projects.indexOf(this.baseline)) {
this.monitor = null;
}
}
}
},
setMonitor (project) {
if (project === this.monitor) {
this.monitor = null;
} else {
this.monitor = project;
}
},
clearComparison () {
this.baseline = null;
this.monitor = null;
},
setComparison (baseline, monitor) {
this.clearComparison();
this.setBaseline(baseline);
this.setMonitor(monitor);
},
async getComparisons () {
const url = `/comparison/group/${this.$route.params.group}`;
this.comparisons = await this.api([url]);
},
// TODO Should this go in a Vuex action rather?
async refreshComparisons () {
await this.getGroups();
if (this.groupFound) {
await this.getComparisons();
}
},
/*
async getComparison () {
if (this.baseline && this.monitor) {
const url = `/comparison/group/${this.$route.params.group}/baseline/${this.baseline.pid}/monitor/${this.monitor.pid}`;
const comparison = await this.api([url]);
if (comparison) {
this.comparison = comparison;
}
}
},
*/
handleComparisons (context, {payload}) {
this.refreshComparisons();
},
registerNotificationHandlers (action = "registerHandler") {
this.$store.dispatch(action, {
table: 'comparisons',
handler: this.handleComparisons
});
},
unregisterNotificationHandlers () {
return this.registerNotificationHandlers("unregisterHandler");
},
...mapActions(["api", "getGroups", "refreshProjects"])
},
async mounted () {
this.registerNotificationHandlers();
this.refreshComparisons()
},
beforeDestroy () {
this.unregisterNotificationHandlers();
}
}
</script>

View File

@@ -0,0 +1,396 @@
<template>
<v-container fluid>
<v-data-table
:headers="headers"
:items="displayItems"
item-key="group"
:options.sync="options"
:expanded.sync="expanded"
show-expand
:loading="loading"
>
<template v-slot:item.group="{item, value}">
<v-chip
label
small
:href="`./${value}`"
>{{ value }}</v-chip>
</template>
<template v-slot:item.shots_total="{item, value}">
<div>{{ item.prime + item.other }}</div>
<v-progress-linear
background-color="secondary"
color="primary"
:value="item.prime/(item.prime+item.other)*100"
></v-progress-linear>
</template>
<template v-slot:item.prime="{item, value}">
{{ value }}
({{ (value / (item.prime + item.other) * 100).toFixed(1) }}%)
</template>
<template v-slot:item.other="{item, value}">
{{ value }}
({{ (value / (item.prime + item.other) * 100).toFixed(1) }}%)
</template>
<template v-slot:item.prod_duration="{item, value}">
<span v-if="value.days > 2" :title="`${value.days} d ${value.hours} h ${value.minutes} m ${(value.seconds + value.milliseconds/1000).toFixed(3)} s`">
{{ value.days }} d
</span>
<span v-else>
{{ value.days }} d {{ value.hours }} h {{ value.minutes }} m {{ (value.seconds + value.milliseconds/1000).toFixed(1) }} s
</span>
</template>
<template v-slot:item.prod_distance="{item, value}">
{{ (value/1000).toFixed(1) }} km
</template>
<template v-slot:item.shooting_rate_mean="{item, value}">
{{ (value).toFixed(2) }} s ±{{ (item.shooting_rate_sd).toFixed(3) }} s
</template>
<template v-slot:item.shots_per_point="{item, value}">
<div>
{{ ((item.prime + item.other)/item.points).toFixed(1) }}
({{ ((((item.prime + item.other)/item.points) / item.num_projects)*100).toFixed(2) }}%)
</div>
<v-progress-linear
:value="((((item.prime + item.other)/item.points) / item.num_projects)*100)"
></v-progress-linear>
</template>
<template v-slot:expanded-item="{ headers, item }">
<td :colspan="headers.length">
<v-data-table class="ma-1"
:headers="projectHeaders"
:items="item.projects"
dense
hide-default-footer
>
<template v-slot:item.pid="{item, value}">
<a :href="`/projects/${value}`" title="Go to project">{{ value }}</a>
</template>
<template v-slot:item.fsp="{item, value}">
<span title="First production shot">{{value.tstamp.substr(0, 10)}}</span>
</template>
<template v-slot:item.lsp="{item, value}">
<span title="Last production shot">{{value.tstamp.substr(0, 10)}}</span>
</template>
<template v-slot:item.prod_duration="{item, value}">
<span v-if="value.days > 2" :title="`${value.days} d ${value.hours} h ${value.minutes} m ${(value.seconds + value.milliseconds/1000).toFixed(3)} s`">
{{ value.days }} d
</span>
<span v-else>
{{ value.days }} d {{ value.hours }} h {{ value.minutes }} m {{ (value.seconds + value.milliseconds/1000).toFixed(1) }} s
</span>
</template>
<template v-slot:item.prod_distance="{item, value}">
{{ (value/1000).toFixed(1) }} km
</template>
</v-data-table>
</td>
</template>
</v-data-table>
</v-container>
</template>
<style>
td p:last-of-type {
margin-bottom: 0;
}
</style>
<script>
import { mapActions, mapGetters } from 'vuex';
import AccessMixin from '@/mixins/access';
// FIXME send to lib/utils or so
/*
function duration_to_ms(v) {
if (v instanceof Object) {
return (
(v.days || 0) * 86400000 +
(v.hours || 0) * 3600000 +
(v.minutes || 0) * 60000 +
(v.seconds || 0) * 1000 +
(v.milliseconds || 0)
);
} else {
return {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0
}
}
}
function ms_to_duration(v) {
const days = Math.floor(v / 86400000);
v %= 86400000;
const hours = Math.floor(v / 3600000);
v %= 3600000;
const minutes = Math.floor(v / 60000);
v %= 60000;
const seconds = Math.floor(v / 1000);
const milliseconds = v % 1000;
return { days, hours, minutes, seconds, milliseconds };
}
function normalise_duration (v) {
return ms_to_duration(duration_to_ms(v));
}
function add_durations(a, b) {
return ms_to_duration(duration_to_ms(a) + duration_to_ms(b));
}
*/
export default {
name: "GroupList",
components: {
},
mixins: [
AccessMixin
],
data () {
return {
headers: [
{
value: "group",
text: "Group name"
},
{
value: "num_projects",
text: "Number of campaigns"
},
{
value: "lines",
text: "Preplot lines"
},
{
value: "points",
text: "Preplot points"
},
{
value: "sequences",
text: "Total sequences"
},
{
value: "shots_total",
text: "Total shots"
},
{
value: "prime",
text: "Total prime"
},
{
value: "other",
text: "Total reshoot + infill"
},
/*
{
value: "ntba",
text: "Total NTBA"
},
*/
{
value: "prod_duration",
text: "Total duration"
},
{
value: "prod_distance",
text: "Total distance"
},
{
value: "shooting_rate_mean",
text: "Shooting rate (mean)"
},
{
value: "shots_per_point",
text: "Shots per point"
},
],
items: [],
expanded: [],
options: { sortBy: ["group"], sortDesc: [false] },
projectHeaders: [
{
value: "pid",
text: "ID"
},
{
value: "name",
text: "Name"
},
{
value: "fsp",
text: "Start"
},
{
value: "lsp",
text: "Finish"
},
{
value: "lines",
text: "Preplot lines"
},
{
value: "seq_final",
text: "Num. of sequences"
},
{
value: "prod_duration",
text: "Duration"
},
{
value: "prod_distance",
text: "Distance"
},
],
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null,
/*
// FIXME Eventually need to move this into Vuex
groups: []
*/
}
},
computed: {
displayItems () {
return this.items.filter(i => i.prod_distance);
},
...mapGetters(['loading', 'groups'])
},
methods: {
/*
async prepareGroups () {
//const groups = await this.api(["/prospects"]);
//console.log("groups", groups);
const groups = {};
for (const project of this.projects) {
if (!project.prod_distance) {
// This project has no production data (either not started yet
// or production data has not been imported) so we skip it.
continue;
}
if (!project.prod_duration.days) {
project.prod_duration = normalise_duration(project.prod_duration);
}
for (const name of project.groups) {
if (!(name in groups)) {
groups[name] = {
group: name,
num_projects: 0,
lines: 0,
points: 0,
sequences: 0,
// Shots:
prime: 0,
other: 0,
ntba: 0,
prod_duration: {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0
},
prod_distance: 0,
shooting_rate: [],
projects: []
};
}
const group = groups[name];
group.num_projects++;
group.lines = Math.max(group.lines, project.lines); // In case preplots changed
group.points = Math.max(group.points, project.total); // Idem
group.sequences += project.seq_final;
group.prime += project.prime;
group.other += project.other;
//group.ntba += project.ntba;
group.prod_duration = add_durations(group.prod_duration, project.prod_duration);
group.prod_distance += project.prod_distance;
group.shooting_rate.push(project.shooting_rate);
group.projects.push(project);
}
}
this.groups = [];
for (const group of Object.values(groups)) {
group.shooting_rate_mean = d3a.mean(group.shooting_rate);
group.shooting_rate_sd = d3a.deviation(group.shooting_rate);
delete group.shooting_rate;
this.groups.push(group);
}
},
*/
async list () {
this.items = [...this.groups];
},
async load () {
await this.refreshProjects();
//await this.prepareGroups();
await this.list();
},
registerNotificationHandlers () {
this.$store.dispatch('registerHandler', {
table: 'project`',
handler: (context, message) => {
if (message.payload?.table == "public") {
this.load();
}
}
});
},
...mapActions(["api", "showSnack", "refreshProjects"])
},
mounted () {
this.registerNotificationHandlers();
this.load();
}
}
</script>

View File

@@ -5,6 +5,22 @@
<v-card-title>
<v-toolbar flat>
<v-toolbar-title>
<template v-if="$route.params.sequence">
<v-btn icon small
:disabled="sequenceIndex >= (sequences.length - 1)"
:to="{name: 'logBySequence', params: { sequence: (sequences[sequences.length-1]||{}).sequence }}"
title="Go to the first sequence"
>
<v-icon dense>mdi-chevron-double-left</v-icon>
</v-btn>
<v-btn icon small
:disabled="sequenceIndex >= (sequences.length - 1)"
:to="{name: 'logBySequence', params: { sequence: (sequences[sequenceIndex+1]||{}).sequence }}"
title="Go to the previous sequence"
>
<v-icon dense>mdi-chevron-left</v-icon>
</v-btn>
</template>
<span class="d-none d-lg-inline">
{{
$route.params.sequence
@@ -31,7 +47,23 @@
: ""
}}
</span>
</v-toolbar-title>
<template v-if="$route.params.sequence">
<v-btn icon small
:disabled="sequenceIndex==0"
:to="{name: 'logBySequence', params: { sequence: (sequences[sequenceIndex-1]||{}).sequence }}"
title="Go to the next sequence"
>
<v-icon dense>mdi-chevron-right</v-icon>
</v-btn>
<v-btn icon small class="mr-1"
:disabled="sequenceIndex==0"
:to="{name: 'logBySequence', params: { sequence: (sequences[0]||{}).sequence }}"
title="Go to the last sequence"
>
<v-icon dense>mdi-chevron-double-right</v-icon>
</v-btn>
</template>
<a v-if="$route.params.sequence"
class="mr-3"
@@ -44,6 +76,10 @@
>mdi-format-list-numbered</v-icon>
</a>
</v-toolbar-title>
<dougal-event-edit v-if="$parent.writeaccess()"
v-model="eventDialog"
v-bind="editedEvent"
@@ -325,6 +361,12 @@
@click="labelSearch=label"
>{{label}}</v-chip>
</span>
<v-icon v-if="entry.meta.auto || entry.meta.author"
x-small
left
color="primary"
:title="entry.meta.author?`Automatic event by ${entry.meta.author}`:'Automatic event'"
>mdi-robot</v-icon>
<dougal-event-edit-history v-if="entry.has_edits && $parent.writeaccess()"
:id="entry.id"
:disabled="eventsLoading"
@@ -488,17 +530,6 @@ export default {
rows () {
const rows = {};
this.items
.filter(i => {
return !this.$route.params.sequence || (this.$route.params.sequence == i.sequence)
})
.filter(i => {
for (const label of this.filterableLabels) {
if (!this.shownLabels.includes(label) && i.labels.includes(label)) {
return false;
}
}
return true;
})
.forEach(i => {
const key = (i.sequence && i.point) ? (i.sequence+"@"+i.point) : i.tstamp;
if (!rows[key]) {
@@ -529,6 +560,10 @@ export default {
.sort( (a, b) => b[1]-a[1] );
},
filteredLabels () {
return this.filterableLabels.filter( label => !this.shownLabels.includes(label) );
},
presetRemarks () {
return this.projectConfiguration?.events?.presetRemarks ?? [];
},
@@ -541,7 +576,17 @@ export default {
}
},
...mapGetters(['user', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
sequenceIndex () {
if ("sequence" in this.$route.params) {
const index = this.sequences.findIndex( i => i.sequence == this.$route.params.sequence );
if (index != -1) {
return index;
}
}
// return undefined
},
...mapGetters(['user', 'eventsLoading', 'online', 'sequence', 'sequences', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
...mapState({projectSchema: state => state.project.projectSchema})
},
@@ -549,6 +594,7 @@ export default {
watch: {
options: {
async handler () {
this.savePrefs(),
await this.fetchEvents();
},
deep: true
@@ -567,12 +613,19 @@ export default {
},
filter (newVal, oldVal) {
this.savePrefs();
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchEvents();
}
},
labelSearch () {
this.savePrefs();
this.fetchEvents();
},
filteredLabels () {
this.savePrefs()
this.fetchEvents();
},
@@ -581,7 +634,7 @@ export default {
},
user (newVal, oldVal) {
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
this.loadPrefs();
}
},
@@ -632,8 +685,10 @@ export default {
async fetchEvents (opts = {}) {
const options = {
sequence: this.$route.params.sequence,
text: this.filter,
label: this.labelSearch,
excludeLabels: this.filteredLabels,
...this.options
};
const res = await this.getEvents([this.$route.params.project, options]);
@@ -737,6 +792,13 @@ export default {
if (event.id) {
const id = event.id;
delete event.id;
// If this is an edit, ensure that it is *either*
// a timestamp event or a sequence + point one.
if (event.sequence && event.point && event.tstamp) {
delete event.tstamp;
}
this.putEvent(id, event, callback); // No await
} else {
this.postEvent(event, callback); // No await
@@ -829,7 +891,7 @@ export default {
viewOnMap(item) {
if (item?.meta && item.meta?.geometry?.type == "Point") {
const [ lon, lat ] = item.meta.geometry.coordinates;
return `map#15/${lon.toFixed(6)}/${lat.toFixed(6)}`;
return `map#z15x${lon.toFixed(6)}y${lat.toFixed(6)}::${lon.toFixed(6)},${lat.toFixed(6)}`;
} else if (item?.items) {
return this.viewOnMap(item.items[0]);
}
@@ -864,10 +926,36 @@ export default {
*/
},
getPrefsKey () {
return `dougal/prefs/${this.user?.name}/${this.$route.params.project}/Log/v1`;
},
savePrefs () {
const prefs = {
shownLabels: this.shownLabels,
labelSearch: this.labelSearch,
filter: this.filter,
options: this.options
};
localStorage.setItem(this.getPrefsKey(), JSON.stringify(prefs));
},
loadPrefs () {
const stored = localStorage.getItem(this.getPrefsKey());
if (stored) {
const prefs = JSON.parse(stored);
if (prefs.shownLabels !== undefined) this.shownLabels = prefs.shownLabels;
if (prefs.labelSearch !== undefined) this.labelSearch = prefs.labelSearch;
if (prefs.filter !== undefined) this.filter = prefs.filter;
if (prefs.options !== undefined) this.options = prefs.options;
}
},
...mapActions(["api", "showSnack", "refreshEvents", "getEvents"])
},
async mounted () {
this.loadPrefs();
this.fetchEvents();
window.addEventListener('keyup', this.handleKeyboardEvent);

View File

@@ -81,6 +81,13 @@ export default {
await this.logout();
await this.login(this.credentials);
if (this.user) {
console.log("Login successful");
// Should trigger auto-refresh over ws as well as authenticating the
// user over ws.
this.$root.sendJwt();
}
if (this.user && !this.user.autologin) {
this.$router.replace("/");
} else {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,761 @@
<script>
// Important info about performance:
// https://deck.gl/docs/developer-guide/performance#supply-attributes-directly
import { Deck, WebMercatorViewport, FlyToInterpolator, CompositeLayer } from '@deck.gl/core';
import { GeoJsonLayer, LineLayer, PathLayer, BitmapLayer, ScatterplotLayer, ColumnLayer, IconLayer } from '@deck.gl/layers';
import {HeatmapLayer} from '@deck.gl/aggregation-layers';
import { TileLayer, MVTLayer, TripsLayer } from '@deck.gl/geo-layers';
import { SimpleMeshLayer } from '@deck.gl/mesh-layers';
import { OBJLoader } from '@loaders.gl/obj';
//import { json } from 'd3-fetch';
import * as d3a from 'd3-array';
import { DougalBinaryBundle, DougalBinaryChunkSequential, DougalBinaryChunkInterleaved } from '@dougal/binary';
import { DougalShotLayer } from '@/lib/deck.gl';
import { DougalSequenceLayer, DougalEventsLayer } from '@/lib/deck.gl';
import DougalBinaryLoader from '@/lib/deck.gl/DougalBinaryLoader';
import { colors } from 'vuetify/lib'
function hexToArray (hex, defaultValue = [ 0xc0, 0xc0, 0xc0, 0xff ]) {
if (typeof hex != "string" || hex.length < 6) {
return defaultValue;
}
if (hex[0] == "#") {
hex = hex.slice(1); // remove the '#' character
}
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
hex.length > 6 ? parseInt(hex.slice(6, 8), 16) : 255
];
}
function namedColourToArray (name) {
const parts = name.split(/\s+/).map( (s, i) =>
i
? s.replace("-", "")
: s.replace(/-([a-z])/g, (match, group1) => group1.toUpperCase())
);
parts[0]
if (parts.length == 1) parts[1] = "base";
const hex = parts.reduce((acc, key) => acc[key], colors);
return hexToArray(hex);
}
export default {
name: "MapLayersMixin",
data () {
return {
COLOUR_SCALE_1: [
// negative
[65, 182, 196],
[127, 205, 187],
[199, 233, 180],
[237, 248, 177],
// positive
[255, 255, 204],
[255, 237, 160],
[254, 217, 118],
[254, 178, 76],
[253, 141, 60],
[252, 78, 42],
[227, 26, 28],
[189, 0, 38],
[128, 0, 38]
]
};
},
methods: {
normalisedColourScale(v, scale = this.COLOUR_SCALE_1, min = 0, max = 1) {
const range = max-min;
const i = Math.min(scale.length, Math.max(Math.round((v-min) / range * scale.length), 0));
//console.log(`v=${v}, scale.length=${scale.length}, min=${min}, max=${max}, i=${i}, → ${scale[i]}`);
return scale[i];
},
makeDataFromBinary ( {positions, values, udv} ) {
const totalCount = positions.length / 2;
const attributes = {
getPosition: {
value: positions,
type: 'float32',
size: 2
},
udv
};
values.forEach((valArray, k) => {
let value = valArray;
if (valArray instanceof BigUint64Array) {
value = Float64Array.from(valArray, v => Number(v));
}
attributes[`value${k}`] = {
value,
type: value instanceof Float64Array ? 'float64' :
value instanceof Uint16Array ? 'uint16' :
value instanceof Uint32Array ? 'uint32' : 'float32',
size: 1
};
});
return {
length: totalCount,
attributes
};
},
loadOptions (options = {}) {
return {
loadOptions: {
fetch: {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.$store.getters.jwt}`,
}
},
...options
},
};
},
osmLayer (options = {}) {
return new TileLayer({
id: "osm",
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
data: 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
},
// OSM tiles layer. Handy to make water transparent
// but not super reliable yet
osmVectorLayer (options = {}) {
return new MVTLayer({
id: 'osm',
data: 'https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt',
minZoom: 0,
maxZoom: 14,
getFillColor: feature => {
const layer = feature.properties.layerName;
//console.log("layer =", layer, feature.properties.kind);
switch (layer) {
case "ocean":
return [0, 0, 0, 0];
case "land":
return [ 0x54, 0x6E, 0x7A, 255 ];
default:
return [ 240, 240, 240, 255 ];
}
},
getLineColor: feature => {
if (feature.properties.layer === 'water') {
return [0, 0, 0, 0]; // No outline for water
}
return [192, 192, 192, 255]; // Default line color for roads, etc.
},
getLineWidth: feature => {
if (feature.properties.highway) {
return feature.properties.highway === 'motorway' ? 6 : 3; // Example road widths
}
return 1;
},
stroked: true,
filled: true,
pickable: true
});
},
openSeaMapLayer (options = {}) {
return new TileLayer({
id: "sea",
data: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
},
// Norwegian nautical charts
// As of 2025, not available for some weird reason
nauLayer (options = {}) {
return new TileLayer({
id: "nau",
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
data: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=sjokartraster&zoom={z}&x={x}&y={y}',
minZoom: 0,
maxZoom: 19,
tileSize: 256,
renderSubLayers: props => {
const {
bbox: {west, south, east, north}
} = props.tile;
return new BitmapLayer(props, {
data: null,
image: props.data,
bounds: [west, south, east, north]
});
},
...options
})
},
vesselTrackPointsLayer (options = {}) {
if (!this.vesselPosition) return;
return new SimpleMeshLayer({
id: 'navp',
data: [ this.vesselPosition ],
//getColor: [ 255, 48, 0 ],
getColor: [ 174, 1, 174 ],
getOrientation: d => [0, (270 - (d.heading ?? d.cmg ?? d.bearing ?? d.lineBearing ?? 0)) % 360 , 0],
getPosition: d => [ d.x, d.y ],
mesh: `/assets/boat0.obj`,
sizeScale: 0.1,
loaders: [OBJLoader],
pickable: true,
...options
});
},
vesselTrackLinesLayer (options = {}) {
const cfg = this.vesselTrackPeriodSettings[this.vesselTrackPeriod];
let ts1 = new Date(this.vesselTrackLastRefresh*1000);
let ts0 = new Date(ts1.valueOf() - cfg.offset);
let di = cfg.decimation;
let l = 10000;
const breakLimit = (di ? di*20 : 5 * 60) * 1000;
let trailLength = (ts1 - ts0) / 1000;
return new TripsLayer({
id: 'navl',
data: `/api/vessel/track/?di=${di}&l=${l}&project=&ts0=${ts0.toISOString()}&ts1=${ts1.toISOString()}`,
...this.loadOptions({
fetch: {
method: 'GET',
headers: {
Authorization: `Bearer ${this.$store.getters.jwt}`,
}
}
}),
dataTransform: (data) => {
if (data.length >= l) {
console.warn(`Vessel track data may be truncated! Limit: ${l}`);
}
const paths = [];
let prevTstamp;
paths.push({path: [], timestamps: [], num: 0, ts0: +Infinity, ts1: -Infinity});
for (const el of data) {
const tstamp = new Date(el.tstamp).valueOf();
const curPath = () => paths[paths.length-1];
if (prevTstamp && Math.abs(tstamp - prevTstamp) > breakLimit) {
// Start a new path
console.log(`Breaking path on interval ${Math.abs(tstamp - prevTstamp)} > ${breakLimit}`);
paths.push({path: [], timestamps: [], num: paths.length, ts0: +Infinity, ts1: -Infinity});
}
if (tstamp < curPath().ts0) {
curPath().ts0 = tstamp;
}
if (tstamp > curPath().ts1) {
curPath().ts1 = tstamp;
}
curPath().path.push([el.x, el.y]);
curPath().timestamps.push(tstamp/1000);
prevTstamp = tstamp;
}
paths.forEach (path => {
path.nums = paths.length;
path.ts0 = new Date(path.ts0);
path.ts1 = new Date(path.ts1);
});
return paths;
},
getPath: d => d.path,
getTimestamps: d => d.timestamps,
currentTime: ts1.valueOf() / 1000,
trailLength,
widthUnits: "meters",
widthMinPixels: 4,
getWidth: 10,
getColor: [ 174, 1, 126, 200 ],
stroked: true,
pickable: true,
...options
});
},
eventsLogLayer (options = {}) {
const labelColour = (d, i, t, c = [127, 65, 90]) => {
const label = d?.properties?.labels?.[0];
const colour = this.labels[label]?.view?.colour ?? "#cococo";
if (colour) {
if (colour[0] == "#") {
c = hexToArray(colour);
} else {
c = namedColourToArray(colour);
}
} else {
//return [127, 65, 90];
}
if (t != null) {
c[3] = t;
}
return c;
};
return new DougalEventsLayer({
id: 'log',
data: `/api/project/${this.$route.params.project}/event?mime=application/geo%2Bjson`,
...this.loadOptions(),
lineWidthMinPixels: 2,
getPosition: d => d.geometry.coordinates,
jitter: 0.00015,
getElevation: d => Math.min(Math.max(d.properties.remarks?.length || 10, 10), 200),
getFillColor: (d, i) => labelColour(d, i, 200),
getLineColor: (d, i) => labelColour(d, i, 200),
radius: 0.001,
radiusScale: 1,
// This just won't work with radiusUnits = "pixels".
// See: https://grok.com/share/c2hhcmQtMw%3D%3D_16578be4-20fd-4000-a765-f082503d0495
radiusUnits: "pixels",
radiusMinPixels: 1.5,
radiusMaxPixels: 2.5,
pickable: true,
...options
})
},
preplotSaillinesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'psll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?class=V&v=${this.lineTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
preplotLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'ppll',
data: `/api/project/${this.$route.params.project}/gis/preplot/line?v=${this.lineTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntba ? [240, 248, 255, 200] : [85, 170, 255, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
plannedLinesLinesLayer (options = {}) {
return new PathLayer({
id: 'planl',
data: [...this.plannedSequences], // Create new array to trigger Deck.gl update
dataTransform: (sequences) => {
// Raise the data 10 m above ground so that it's visible over heatmaps, etc.
return sequences.map( seq => ({
...seq,
geometry: {
...seq.geometry,
coordinates: seq.geometry.coordinates.map( pos => [...pos, 10] )
}
}))
},
getPath: d => d.geometry.coordinates,
//getSourcePosition: d => d.geometry.coordinates[0],
//getTargetPosition: d => d.geometry.coordinates[1],
widthUnits: "meters",
widthMinPixels: 4,
getWidth: 25,
//getLineWidth: 10,
getColor: (d) => {
const k = (d?.azimuth??0)/360*255;
return [ k, 128, k, 200 ];
},
stroked: true,
pickable: true,
...options
});
},
rawSequencesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'seqrl',
data: `/api/project/${this.$route.params.project}/gis/raw/line?v=${this.sequenceTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.ntbp ? [0xe6, 0x51, 0x00, 200] : [0xff, 0x98, 0x00, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
finalSequencesLinesLayer (options = {}) {
return new GeoJsonLayer({
id: 'seqfl',
data: `/api/project/${this.$route.params.project}/gis/final/line?v=${this.sequenceTStamp?.valueOf()}`,
...this.loadOptions(),
lineWidthMinPixels: 1,
getLineColor: (d) => d.properties.pending ? [0xa7, 0xff, 0xab, 200] : [0x00, 0x96, 0x88, 200],
getLineWidth: 1,
getPointRadius: 2,
radiusUnits: "pixels",
pointRadiusMinPixels: 2,
pickable: true,
...options
})
},
preplotSaillinesPointLayer (options = {}) {
return new DougalSequenceLayer({
id: 'pslp',
data: `/api/project/${this.$route.params.project}/line/sail?v=${this.lineTStamp?.valueOf()}`, // API endpoint returning binary data
loaders: [DougalBinaryLoader],
...this.loadOptions({
fetch: {
method: 'GET',
headers: {
Authorization: `Bearer ${this.$store.getters.jwt}`,
Accept: 'application/vnd.aaltronav.dougal+octet-stream'
}
}
}),
getRadius: 2,
getFillColor: (d, {data, index}) => data.attributes.value2.value[index] ? [240, 248, 255, 200] : [85, 170, 255, 200],
//getFillColor: [0, 120, 220, 200],
pickable: true,
...options
});
},
preplotPointsLayer (options = {}) {
return new DougalSequenceLayer({
id: 'pplp',
data: `/api/project/${this.$route.params.project}/line/source?v=${this.lineTStamp?.valueOf()}`, // API endpoint returning binary data
loaders: [DougalBinaryLoader],
...this.loadOptions({
fetch: {
method: 'GET',
headers: {
Authorization: `Bearer ${this.$store.getters.jwt}`,
Accept: 'application/vnd.aaltronav.dougal+octet-stream'
}
}
}),
getRadius: 2,
getFillColor: (d, {data, index}) => data.attributes.value2.value[index] ? [240, 248, 255, 200] : [85, 170, 255, 200],
//getFillColor: [0, 120, 220, 200],
pickable: true,
...options
});
},
plannedLinesPointsLayer (options = {}) {
},
rawSequencesPointsLayer (options = {}) {
return new DougalSequenceLayer({
id: 'seqrp',
data: this.makeDataFromBinary(this.sequenceBinaryData),
getRadius: 2,
getFillColor: [0, 120, 220, 200],
pickable: true,
...options
});
},
finalSequencesPointsLayer (options = {}) {
return new DougalSequenceLayer({
id: 'seqfp',
data: this.makeDataFromBinary(this.sequenceBinaryDataFinal),
getRadius: 2,
getFillColor: [220, 120, 0, 200],
pickable: true,
...options
});
},
heatmapLayer(options = {}) {
const { positions, values } = this.heatmapValue?.startsWith("co_")
? this.sequenceBinaryDataFinal
: this.sequenceBinaryData;
if (!positions?.length || !values?.length) {
console.warn('No valid data for heatmapLayer');
return new HeatmapLayer({
id: 'seqrh',
data: [],
...options
});
}
let weights, offset = 0, scaler = 1;
let colorDomain = null;
let aggregation = "MEAN";
let transform = (v) => v;
switch (this.heatmapValue) {
case "total_error":
weights = Float32Array.from(values[3], (ei, i) => {
const ej = values[4][i];
return Math.sqrt(ei * ei + ej * ej) / 100; // Euclidean distance in meters
});
colorDomain = [2, 20]; // scale: 1 (already divided by 100 above)
break;
case "delta_i":
weights = values[3];
scaler = 0.1;
colorDomain = [100, 1200]; // scale: 100 (1 12 m)
break;
case "delta_j":
weights = values[4];
scaler = 0.1;
colorDomain = [10, 80]; // scale: 100 (0.1 0.8 m)
break;
case "co_total_error":
weights = Float32Array.from(values[3], (ei, i) => {
const ej = values[4][i];
return Math.sqrt(ei * ei + ej * ej) / 100; // Euclidean distance in meters
});
colorDomain = [10, 150]; // Scale: 100 (0.1 1 m)
break;
case "co_delta_i":
weights = values[5];
scaler = 0.1;
colorDomain = [10, 150];
break;
case "co_delta_j":
weights = values[6];
scaler = 0.1;
colorDomain = [0.2, 2];
break;
case "delta_μ":
weights = values[5];
scaler = 0.1;
break;
case "delta_σ":
weights = values[6];
scaler = 0.1;
colorDomain = [ 0.1, 1.5 ];
break;
case "delta_R":
weights = values[7];
scaler = 0.1;
colorDomain = [ 0.5, 1.0 ];
break;
case "press_μ":
weights = values[8];
offset = -2000;
colorDomain = [ 5, 50 ];
break;
case "press_σ":
weights = values[9];
colorDomain = [ 1, 19 ];
break;
case "press_R":
weights = values[10];
colorDomain = [ 3, 50 ];
break;
case "depth_μ":
weights = values[11];
offset = -6;
scaler = 0.1;
colorDomain = [ 0.1, 1 ];
break;
case "depth_σ":
weights = values[12];
scaler = 0.1;
break;
case "depth_R":
weights = values[13];
scaler = 0.1;
break;
case "fill_μ":
weights = values[14];
colorDomain = [ 300, 1000 ];
break;
case "fill_σ":
weights = values[15];
offset = -250;
colorDomain = [ 0, 250 ];
break;
case "fill_R":
weights = values[16];
offset = -500;
colorDomain = [ 0, 500 ];
break;
case "delay_μ":
weights = values[17];
offset = -150;
colorDomain = [ 1.5, 25 ];
//transform = (v) => {console.log("τ(v)", v); return v;};
break;
case "delay_σ":
weights = values[18];
break;
case "delay_R":
weights = values[19];
break;
case "no_fire":
weights = values[20];
transform = v => v >> 4;
aggregation = "SUM";
colorDomain = [ 0.1, 1.5 ];
break;
case "autofire":
weights = values[20];
transform = v => v & 0xf;
aggregation = "SUM";
colorDomain = [ 0.5, 1.5 ];
break;
case "misfire":
weights = values[20];
aggregation = "SUM";
colorDomain = [ 0.5, 1.5 ];
break;
}
const stats = {
min: d3a.min(weights),
mode: d3a.mode(weights),
mean: d3a.mean(weights),
max: d3a.max(weights),
sd: d3a.deviation(weights),
};
const sr0 = [ stats.mean - 2.1*stats.sd, stats.mean + 2.1*stats.sd ];
const sr1 = [ stats.mode - 2.1*stats.sd, stats.mode + 2.1*stats.sd ];
/*
console.log('Positions sample:', positions.slice(0, 10));
console.log('Weights sample:', weights.slice(0, 10));
console.log("Mode:", this.heatmapValue);
console.log('Weight stats:', stats);
console.log("Suggested ranges");
console.log(sr0);
console.log(sr1);
console.log("Actual ranges");
console.log(colorDomain);
*/
return new HeatmapLayer({
id: 'seqrh',
data: {
length: weights.length,
positions,
weights
/*
attributes: {
getPosition: { value: positions, type: 'float32', size: 2 },
getWeight: { value: weights, type: 'float32', size: 1 }
}
*/
},
getPosition: (d, {index, data}) => [ data.positions[index*2], data.positions[index*2+1] ],
getWeight: (d, {index, data}) => transform(Math.abs(data.weights[index] * scaler + offset)),
colorDomain,
radiusPixels: 25,
aggregation,
pickable: false,
...options
});
},
},
}
</script>

View File

@@ -0,0 +1,259 @@
<script>
import * as d3a from 'd3-array';
export default {
name: "MapTooltipsMixin",
data () {
return {
tooltipDefaultStyle: { "max-width": "50ex"}
}
},
methods: {
getTooltip (args) {
if (args?.layer?.constructor?.tooltip) {
return args.layer.constructor.tooltip(args);
} else if (args?.layer?.id == "seqrp" || args?.layer?.id == "seqfp") {
return this.sequencePointsTooltip(args);
} else if (args?.layer?.id == "log") {
return this.eventLogTooltip(args);
} else if (args?.layer?.id == "pplp" || args?.layer?.id == "pslp") {
return this.preplotPointsTooltip(args);
} else if (args?.layer?.id == "ppll" || args?.layer?.id == "psll") {
return this.preplotLinesTooltip(args);
} else if (args?.layer?.id == "planl") {
return this.plannedLinesTooltip(args);
} else if (args?.layer?.id == "seqrl" || args?.layer?.id == "seqfl") {
return this.sequenceLinesTooltip(args);
} else if (args?.layer?.id == "navp") {
return this.vesselTrackPointsTooltip(args);
} else if (args?.layer?.id == "navl") {
return this.vesselTrackLinesTooltip(args);
}
},
preplotPointsTooltip (args) {
const p = args?.object;
if (p) {
let html = "Preplot<br/>\n";
if ("sailline" in p) {
// If there is a "sailline" attribute, this is actually a source line
// "i" is the source line number, "sailline" the sail line number.
html += `S${String(p.i).padStart(4, "0")}<br/>\n`;
html += `V${String(p.sailline).padStart(4, "0")}<br/>\n`;
} else {
html += `V${String(p.i).padStart(4, "0")}<br/>\n`;
}
html += `P${String(p.j).padStart(4, "0")}<br/>\n`;
if (p.sailline_ntba) {
html += `<b>Line <abbr title="Not to be acquired">NTBA</abbr></b><br/>\n`;
}
if (p.ntba) {
html += `<b>Point <abbr title="Not to be acquired">NTBA</abbr></b><br/>\n`;
}
return {html, style: this.tooltipDefaultStyle};
}
},
preplotLinesTooltip (args) {
const p = args?.object?.properties;
if (p) {
const lineType = args.layer.id == "psll" ? "Sailline" : "Source line";
const direction = p.incr ? "▲" : "▼";
let html = "";
html += `L${String(p.line).padStart(4, "0")} ${direction}<br/>\n`;
html += `${lineType}<br/>\n`;
if (p.ntba) {
html += "<b>Not to be acquired (NTBA)</b><br/>\n";
}
if (p.remarks) {
html += `<hr/>\n${this.$root.markdown(p.remarks)}\n`;
}
return {html, style: this.tooltipDefaultStyle};
}
},
sequenceLinesTooltip (args) {
let type;
switch(args.layer.id) {
case "seqrl":
type = "Raw";
break;
case "seqfl":
type = "Final";
break;
}
const p = args?.object?.properties;
if (p) {
let html = `Sequence ${p.sequence} (${type})<br/>\n`;
html += `Line <b>${p.line}</b><br/>\n`;
html += `${p.num_points} points (${p.missing_shots ? (p.missing_shots + " missing") : "None missing"})<br/>\n`;
html+= `${(p.length??0).toFixed(0)} m ${(p.azimuth??0).toFixed(1)}°<br/>\n`;
html += `${p.duration}<br/>\n`;
html += `<b>${p.fsp}</b> @ ${(new Date(p.ts0))?.toISOString()}<br/>\n`;
html += `<b>${p.lsp}</b> @ ${(new Date(p.ts1))?.toISOString()}<br/>\n`;
if (p.ntbp) {
html += "<b>Not to be processed</b><br/>\n";
} else if (p.pending) {
html += "<b>Pending</b><br/>\n";
}
html += "<hr/><br/>\n";
html += this.$root.markdown(p.remarks);
return {html, style: this.tooltipDefaultStyle};
}
},
sequencePointsTooltip (args) {
const p = args?.object;
if (p) {
let html = "";
if (p.udv == 2) { // FIXME Must change this to something more meaningful
// This is a shot info record:
html += `S${p.i.toString().padStart(3, "0")} ${p.j}</br>\n`;
html += `<small>${new Date(Number(p.ts)).toISOString()}</small><br/>\n`;
html += `Δj: ${p.εj.toFixed(2)} m / Δi: ${p.εi.toFixed(2)} m<br/>\n`;
html += `<hr/>\n`;
if (p.nofire) {
html += `<b>No fire: ${p.nofire} guns</b><br/>\n`;
}
if (p.autofire) {
html += `<b>Autofire: ${p.autofire} guns</b><br/>\n`;
}
html += `P: ${p.press} psi ±${p.press_σ} psi (R=${p.press_R})<br/>\n`;
html += `Δ: ${p.delta} ms ±${p.delta_σ} ms (R=${p.delta_R})<br/>\n`;
html += `D: ${p.depth} m ±${p.depth_σ} m (R=${p.depth_R})<br/>\n`;
html += `F: ${p.fill} ms ±${p.fill_σ} ms (R=${p.fill_R})<br/>\n`;
html += `W: ${p.delay} ms ±${p.delay_σ} ms (R=${p.delay_R})<br/>\n`;
return {html, style: this.tooltipDefaultStyle};
} else if (p.udv == 3) {
// A final points record
html += `S${p.i.toString().padStart(3, "0")} ${p.j} (final)</br>\n`;
html += `<small>${new Date(Number(p.ts)).toISOString()}</small><br/>\n`;
html += `Δj: ${p.εj.toFixed(2)} m / Δi: ${p.εi.toFixed(2)} m (finalpreplot)<br/>\n`;
html += `δj: ${p.co_j.toFixed(2)} m / δi: ${p.co_i.toFixed(2)} m (finalraw)<br/>\n`;
return {html, style: this.tooltipDefaultStyle};
}
}
console.log("no tooltip");
},
eventLogTooltip (args) {
const p = args?.object?.properties;
if (p) {
let html = "";
if (p.sequence && p.point) {
html += `S${p.sequence.toString().padStart(3, "0")} ${p.point}</br>\n`;
}
html += `<small>${p.tstamp}</small><br/>\n`;
html += `<span>${p.remarks}</span>`;
if (p.labels?.length) {
html += `</br>\n<small><i>${p.labels.join(", ")}</i></small>`;
}
return {html, style: this.tooltipDefaultStyle};
}
},
plannedLinesTooltip (args) {
const p = args?.object;
if (p) {
const duration = `${(p.duration?.hours??0).toString().padStart(2, "0")}:${(p.duration?.minutes??0).toString().padStart(2, "0")}`;
const Δt = ((new Date(p.ts1)).valueOf() - (new Date(p.ts0)).valueOf()) / 1000; // seconds
const speed = (p.length / Δt) * 3.6 / 1.852; // knots
let html = `Planned sequence <b>${p.sequence}</b></br>\n` +
`Line <b>${p.line}</b> ${p.name}</br>\n` +
`${p.num_points} Points</br>\n` +
`${p.length.toFixed(0)} m ${p.azimuth.toFixed(2)}°</br>\n` +
`${duration} @ ${speed.toFixed(1)} kt</br>\n` +
`<b>${p.fsp}</b> @ ${(new Date(p.ts0))?.toISOString()}</br>\n` +
`<b>${p.lsp}</b> @ ${(new Date(p.ts1))?.toISOString()}`;
if (p.remarks) {
html += `</br>\n<hr/>${this.$root.markdown(p.remarks)}`;
}
return {html, style: this.tooltipDefaultStyle};
}
},
vesselTrackPointsTooltip (args) {
const p = args.object;
if (p) {
let html = `${p.vesselName}<br/>\n`
+ `${p.tstamp}<br/>\n`
+ `BSP ${(p.speed??0).toFixed(1)} kt CMG ${(p.cmg??0).toFixed(1).padStart(5, "0")}° HDG ${(p.bearing??0).toFixed(1).padStart(5, "0")}° DPT ${(p.waterDepth??0).toFixed(1)} m<br/>\n`
+ `${p.lineStatus}<br/>\n`;
if (p.guns) {
console.log(p);
const pressure = p.guns.map( i => i[11] ); // 11 is gun pressure
const μpress = d3a.mean(pressure);
const σpress = d3a.deviation(pressure);
if (p.lineStatus && p.lineStatus != "offline") {
html += `${p.lineName}<br/>\n`
+ `S: ${p._sequence} L: ${p._line} P: ${p.shot}<br/>`
+ `Source ${p.src_number} `
+ ((p.trg_mode && p.trg_mode != "external") ? `<b>${p.trg_mode.toUpperCase()} TRIGGER</b> ` : "")
+ `<small>FSID ${p.fsid}</small> <small>mask ${p.mask}</small><br/>\n`
+ `Δ ${(p.avg_delta??0).toFixed(3)} ms ±${(p.std_delta??0).toFixed(3)} ms<br/>\n`
+ `${(μpress??0).toFixed(0)} psi ±${(σpress??0).toFixed(0)} psi / ${(p.volume??0).toFixed(0)} in³<br/>\n`
+ `along ${(p.inline??0).toFixed(1)} m / across ${(p.crossline??0).toFixed(1)} m<br/>\n`;
} else {
// Soft start?
html +=
`Source ${p.src_number} `
+ ((p.trg_mode && p.trg_mode != "external") ? `<b>${p.trg_mode.toUpperCase()} TRIGGER</b> ` : "")
+ `<small>mask ${p.mask}</small><br/>\n`
+ `Δ ${(p.avg_delta??0).toFixed(3)} ms ±${(p.std_delta??0).toFixed(3)} ms<br/>\n`
+ `${(p.manifold??0).toFixed(0)} psi / ${(p.volume??0).toFixed(0)} in³<br/>\n`
+ `along ${(p.inline??0).toFixed(1)} m / across ${(p.crossline??0).toFixed(1)} m<br/>\n`;
}
}
return {html, style: this.tooltipDefaultStyle};
}
},
vesselTrackLinesTooltip (args) {
const p = args.object;
console.log("track lines tooltip", p);
if (p) {
let html = `Segment ${p.num+1} / ${p.nums}<br/>\n`
html += `${p.ts0.toISOString()}<br/>\n`
html += `${p.ts1.toISOString()}<br/>\n`;
return {html, style: this.tooltipDefaultStyle};
}
},
}
}
</script>

View File

@@ -43,48 +43,114 @@ export default {
return this.loading || this.projectId;
},
...mapGetters(["loading", "projectId", "projectSchema", "serverEvent"])
},
watch: {
async serverEvent (event) {
if (event.channel == "project" && event.payload?.operation == "DELETE" && event.payload?.schema == "public") {
// Project potentially deleted
await this.getProject(this.$route.params.project);
} else if (event.payload?.schema == this.projectSchema) {
if (event.channel == "event") {
this.refreshEvents();
} else if (event.channel == "planned_lines") {
this.refreshPlan();
} else if (["raw_lines", "raw_shots", "final_lines", "final_shots"].includes(event.channel)) {
this.refreshSequences();
} else if (["preplot_lines", "preplot_points"].includes(event.channel)) {
this.refreshLines();
} else if (event.channel == "info") {
if ((event.payload?.new ?? event.payload?.old)?.key == "plan") {
this.refreshPlan();
}
} else if (event.channel == "project") {
this.getProject(this.$route.params.project);
}
}
}
...mapGetters(["loading", "projectId", "projectSchema"])
},
methods: {
handleEvents (context, {payload}) {
if (payload.pid != this.projectId) {
console.warn(`${this.projectId} ignoring notification for ${payload.pid}`);
return;
}
this.refreshEvents();
},
handleLines (context, {payload}) {
if (payload.pid != this.projectId) {
console.warn(`${this.projectId} ignoring notification for ${payload.pid}`);
return;
}
this.refreshLines();
},
handlePlannedLines (context, {payload}) {
if (payload.pid != this.projectId) {
console.warn(`${this.projectId} ignoring notification for ${payload.pid}`);
return;
}
this.refreshPlan();
},
handleSequences (context, {payload}) {
if (payload.pid != this.projectId) {
console.warn(`${this.projectId} ignoring notification for ${payload.pid}`);
return;
}
console.log("handleSequences");
this.refreshSequences();
},
registerNotificationHandlers (action = "registerHandler") {
this.$store.dispatch(action, {
table: 'event',
handler: (context, message) => {
this.handleEvents(context, message);
}
});
["preplot_lines", "preplot_points"].forEach( table => {
this.$store.dispatch(action, {
table,
handler: (context, message) => {
this.handleLines(context, message);
}
})
});
this.$store.dispatch(action, {
table: 'planned_lines',
handler: (context, message) => {
this.handlePlannedLines(context, message);
}
});
["raw_lines", "raw_shots", "final_lines", "final_shots"].forEach( table => {
this.$store.dispatch(action, {
table,
handler: (context, message) => {
this.handleSequences(context, message);
}
})
});
},
unregisterNotificationHandlers () {
return this.registerNotificationHandlers("unregisterHandler");
},
...mapActions(["getProject", "refreshLines", "refreshSequences", "refreshEvents", "refreshLabels", "refreshPlan"])
},
async mounted () {
await this.getProject(this.$route.params.project);
if (this.projectFound) {
this.registerNotificationHandlers();
this.refreshLines();
this.refreshSequences();
this.refreshEvents();
this.refreshLabels();
this.refreshPlan();
}
},
beforeDestroy () {
this.unregisterNotificationHandlers();
}
}
</script>

View File

@@ -27,6 +27,8 @@
<v-chip v-for="group in value"
label
small
:title="`View repeatability data for ${group}`"
:href="`/groups/${group}`"
>{{ group }}</v-chip>
</template>
@@ -156,7 +158,7 @@ export default {
},
],
items: [],
options: {},
options: { sortBy: ["pid"], sortDesc: [true] },
// Whether or not to show archived projects
showArchived: true,
@@ -184,17 +186,15 @@ export default {
: this.items.filter(i => !i.archived);
},
...mapGetters(['loading', 'serverEvent', 'projects'])
...mapGetters(['loading', 'projects'])
},
watch: {
async serverEvent (event) {
if (event.channel == "project" && event.payload?.schema == "public") {
if (event.payload?.operation == "DELETE" || event.payload?.operation == "INSERT") {
async projects () {
await this.load();
}
}
}
},
methods: {
@@ -203,13 +203,6 @@ export default {
this.items = [...this.projects];
},
async summary (item) {
const details = await this.api([`/project/${item.pid}/summary`]);
if (details) {
return Object.assign({}, details, item);
}
},
title (item) {
if (item.organisations) {
return "Access:\n" + Object.entries(item.organisations).map( org =>
@@ -220,18 +213,23 @@ export default {
},
async load () {
if (!this.projects.length) {
this.refreshProjects();
}
await this.list();
const promises = [];
for (const key in this.items) {
const item = this.items[key];
const promise = this.summary(item)
.then( expanded => {
if (expanded) {
this.$set(this.items, key, expanded);
},
handlerLoad (context, {payload}) {
if (payload?.table == "public") {
this.load();
}
},
registerNotificationHandlers () {
this.$store.dispatch('registerHandler', {
table: 'project`',
handler: this.handlerLoad
});
promises.push(promise);
}
},
contextMenu (e, {item}) {
@@ -372,10 +370,11 @@ export default {
},
...mapActions(["api", "showSnack"])
...mapActions(["api", "showSnack", "refreshProjects"])
},
mounted () {
this.registerNotificationHandlers();
this.load();
}
}

View File

@@ -822,7 +822,7 @@ export default {
async getNumLines () {
const projectInfo = await this.api([`/project/${this.$route.params.project}`]);
this.num_rows = projectInfo.sequences;
this.num_rows = projectInfo?.sequences ?? 0;
},
async fetchSequences (opts = {}) {

View File

@@ -6,8 +6,42 @@
<v-progress-linear indeterminate v-if="loading"></v-progress-linear>
<v-toolbar flat>
<v-toolbar-title>
<template v-if="$route.params.sequence">
<v-btn icon small
:disabled="sequenceIndex >= (sequences.length - 1)"
:to="{name: 'shotlog', params: { sequence: (sequences[sequences.length-1]||{}).sequence }}"
title="Go to the first sequence"
>
<v-icon dense>mdi-chevron-double-left</v-icon>
</v-btn>
<v-btn icon small
:disabled="sequenceIndex >= (sequences.length - 1)"
:to="{name: 'shotlog', params: { sequence: (sequences[sequenceIndex+1]||{}).sequence }}"
title="Go to the previous sequence"
>
<v-icon dense>mdi-chevron-left</v-icon>
</v-btn>
</template>
Sequence {{sequenceNumber}}
<small :class="statusColour" v-if="sequence">({{sequence.status}})</small>
<template v-if="$route.params.sequence">
<v-btn icon small
:disabled="sequenceIndex==0"
:to="{name: 'shotlog', params: { sequence: (sequences[sequenceIndex-1]||{}).sequence }}"
title="Go to the next sequence"
>
<v-icon dense>mdi-chevron-right</v-icon>
</v-btn>
<v-btn icon small class="mr-1"
:disabled="sequenceIndex==0"
:to="{name: 'shotlog', params: { sequence: (sequences[0]||{}).sequence }}"
title="Go to the last sequence"
>
<v-icon dense>mdi-chevron-double-right</v-icon>
</v-btn>
</template>
</v-toolbar-title>
<a v-if="$route.params.sequence"
@@ -129,7 +163,7 @@
<template v-slot:item.position="{item}">
<a v-if="position(item).latitude"
:href="`/projects/${$route.params.project}/map#15/${position(item).longitude}/${position(item).latitude}`"
:href="`/projects/${$route.params.project}/map#z15x${position(item).longitude}y${position(item).latitude}:+seqrp;+psll:${position(item).longitude},${position(item).latitude}:S${String(item.sequence).padStart(3, '0')} ${item.point}`"
title="View on map"
:target="`/projects/${$route.params.project}/map`"
@click.stop
@@ -352,6 +386,16 @@ export default {
return this.sequences.find(i => i.sequence == this.sequenceNumber);
},
sequenceIndex () {
if ("sequence" in this.$route.params) {
const index = this.sequences.findIndex( i => i.sequence == this.$route.params.sequence );
if (index != -1) {
return index;
}
}
// return undefined
},
remarks () {
return this.sequence?.remarks || "Nil.";
},

View File

@@ -1,4 +1,7 @@
const webpack = require('webpack');
const { execSync } = require('child_process');
const gitDescribe = execSync('git describe --tags --always', { encoding: 'utf8' }).trim();
module.exports = {
"transpileDependencies": [
@@ -21,10 +24,10 @@ module.exports = {
},
proxy: {
"^/api(/|$)": {
target: "http://127.0.0.1:3000",
target: process.env.DOUGAL_API_URL || "http://127.0.0.1:3000",
},
"^/ws(/|$)": {
target: "ws://127.0.0.1:3000",
target: process.env.DOUGAL_API_URL?.replace(/^http(s?):/i, "ws$1:") || "ws://127.0.0.1:3000",
ws: true
}
}
@@ -48,7 +51,11 @@ module.exports = {
// https://github.com/webpack/changelog-v5/issues/10
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
})
}),
new webpack.DefinePlugin({
'process.env.DOUGAL_FRONTEND_VERSION': JSON.stringify(gitDescribe),
}),
],
module: {
rules: [

View File

@@ -69,6 +69,7 @@ const allMeta = (key, value) => {
return { all: [ meta(key, value) ] };
};
//
// NOTICE These routes do not require authentication
//
@@ -83,7 +84,10 @@ app.map({
post: [ mw.user.logout ]
},
'/version': {
get: [ mw.auth.operations, mw.version.get ]
get: [ mw.auth.operations, mw.version.get ],
'/history': {
get: [ /*mw.auth.user,*/ mw.version.history.get ],
}
},
'/': {
get: [ mw.openapi.get ]
@@ -100,6 +104,9 @@ app.use(mw.auth.access.user);
// Don't process the request if the data hasn't changed
app.use(mw.etag.ifNoneMatch);
// Use compression across the board
app.use(mw.compress);
// We must be authenticated before we can access these
app.map({
'/project': {
@@ -114,10 +121,12 @@ app.map({
get: [ mw.auth.access.read, mw.project.summary.get ],
},
'/project/:project/configuration': {
get: [ mw.project.configuration.get ], // Get project configuration
patch: [ mw.auth.access.edit, mw.project.configuration.patch ], // Modify project configuration
put: [ mw.auth.access.edit, mw.project.configuration.put ], // Overwrite configuration
},
'/project/:project/configuration/:path(*)?': {
get: [ mw.auth.access.read, mw.configuration.get ],
},
/*
* GIS endpoints
@@ -152,10 +161,13 @@ app.map({
'/project/:project/line/': {
get: [ mw.auth.access.read, mw.line.list ],
},
'/project/:project/line/:line': {
// get: [ mw.auth.access.read, mw.line.get ],
'/project/:project/line/:line(\\d+)': {
get: [ mw.auth.access.read, mw.line.get ],
patch: [ mw.auth.access.write, mw.line.patch ],
},
'/project/:project/line/:class(sail|source)': {
get: [ mw.auth.access.read, mw.line.get ],
},
/*
* Sequence endpoints
@@ -213,16 +225,28 @@ app.map({
'changes/:since': {
get: [ mw.auth.access.read, mw.event.changes ]
},
// TODO Rename -/:sequence → sequence/:sequence
// NOTE: old alias for /sequence/:sequence
'-/:sequence/': { // NOTE: We need to avoid conflict with the next endpoint ☹
get: [ mw.auth.access.read, mw.event.sequence.get ],
},
':id/': {
'sequence/:sequence/': {
get: [ mw.auth.access.read, mw.event.sequence.get ],
},
':id(\\d+)/': {
get: [ mw.auth.access.read, mw.event.get ],
put: [ mw.auth.access.write, mw.event.put ],
patch: [ mw.auth.access.write, mw.event.patch ],
delete: [mw.auth.access.write, mw.event.delete ]
},
'import': {
put: [ mw.auth.access.write, mw.event.import.csv, mw.event.import.put ],
post: [ mw.auth.access.write, mw.event.import.csv, mw.event.import.put ],
'/:filename': {
put: [ mw.auth.access.read, mw.event.import.csv, mw.event.import.put ],
post: [ mw.auth.access.write, mw.event.import.csv, mw.event.import.put ],
delete: [ mw.auth.access.write, mw.event.import.delete ]
},
},
},
/*
@@ -262,10 +286,6 @@ app.map({
'/project/:project/label/': {
get: [ mw.auth.access.read, mw.label.list ],
// post: [ mw.label.post ],
},
'/project/:project/configuration/:path(*)?': {
get: [ mw.auth.access.read, mw.configuration.get ],
// post: [ mw.auth.access.admin, mw.label.post ],
},
'/project/:project/info/:path(*)': {
get: [ mw.auth.operations, mw.auth.access.read, mw.info.get ],
@@ -294,10 +314,10 @@ app.map({
// // delete: [ mw.permissions.delete ]
// },
'/project/:project/files/:path(*)': {
get: [ mw.auth.access.write, mw.files.get ]
get: [ mw.files.get ]
},
'/files/?:path(*)': {
get: [ mw.auth.access.write, mw.etag.noSave, mw.files.get ]
get: [ mw.etag.noSave, mw.files.get ]
},
'/navdata/': { // TODO These endpoints should probably need read access auth
get: [ mw.etag.noSave, mw.navdata.get ],
@@ -305,6 +325,30 @@ app.map({
get: [ mw.etag.noSave, mw.gis.navdata.get ]
}
},
'/vessel/track': {
get: [ /*mw.etag.noSave,*/ mw.vessel.track.get ], // JSON array
'/line': {
get: [ // GeoJSON Feature: type = LineString
//mw.etag.noSave,
(req, res, next) => { req.query.geojson = 'LineString'; next(); },
mw.vessel.track.get
]
},
'/point': {
get: [ // GeoJSON FeatureCollection: feature types = Point
//mw.etag.noSave,
(req, res, next) => { req.query.geojson = 'Point'; next(); },
mw.vessel.track.get
]
},
'/points': {
get: [ // JSON array of (Feature: type = Point)
mw.etag.noSave,
(req, res, next) => { req.query.geojson = true; next(); },
mw.vessel.track.get
],
},
},
'/info/': {
':path(*)': {
get: [ mw.auth.operations, mw.info.get ],
@@ -313,6 +357,26 @@ app.map({
delete: [ mw.auth.operations, mw.auth.access.write, mw.info.delete ]
}
},
/*
* 4D comparisons
*/
// FIXME no authentication yet!
'/comparison/group': {
get: [ mw.etag.noSave, mw.comparisons.groups.list ],
'/:group': {
get: [ mw.etag.noSave, mw.comparisons.groups.get ],
},
},
/*
* Other endpoints
*/
'/queue/outgoing/': {
'asaqc': {
get: [ mw.etag.noSave, mw.queue.asaqc.get ],

View File

@@ -1,6 +1,7 @@
const { projectOrganisations, vesselOrganisations/*, orgAccess */} = require('../../../lib/db/project/organisations');
const ServerUser = require('../../../lib/db/user/User');
const { Organisations } = require('@dougal/organisations');
const { ERROR, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
/** Second-order function.
* Returns a middleware that checks if the user has access to
@@ -14,11 +15,7 @@ function operation (operation) {
if (req.params.project) {
const projectOrgs = new Organisations(await projectOrganisations(req.params.project));
const availableOrgs = projectOrgs.accessToOperation(operation).filter(user.organisations);
console.log("Operation: ", operation);
console.log("User: ", user.name);
console.log("User orgs: ", user.organisations);
console.log("Project orgs: ", projectOrgs);
console.log("Available orgs: ", availableOrgs);
DEBUG(`operation = ${operation}, user = ${user?.name}, user orgs = %j, project orgs = %j, availableOrgs = %j`, user.organisations.toJSON(), projectOrgs.toJSON(), availableOrgs.toJSON());
if (availableOrgs.length > 0) {
next();
return;
@@ -26,16 +23,13 @@ function operation (operation) {
} else {
const vesselOrgs = new Organisations(await vesselOrganisations());
const availableOrgs = vesselOrgs.accessToOperation(operation).filter(user.organisations);
console.log("Operation: ", operation);
console.log("User: ", user.name);
console.log("User orgs: ", user.organisations);
console.log("Vessel orgs: ", vesselOrgs);
console.log("Available orgs: ", availableOrgs);
DEBUG(`operation = ${operation}, user = ${user?.name}, user orgs = %j, vessel orgs = %j, availableOrgs = %j`, user.organisations.toJSON(), vesselOrgs.toJSON(), availableOrgs.toJSON());
if (availableOrgs.length > 0) {
next();
return;
}
}
DEBUG(`Access denied to operation ${operation}.`);
next({status: 403, message: "Access denied"});
}
}
@@ -67,7 +61,7 @@ async function user (req, res, next) {
async function admin (req, res, next) {
if (req.user) {
const user = new ServerUser(req.user);
if (user.operations.accessToOperation("edit").length > 0) {
if (user.organisations.accessToOperation("edit").length > 0) {
next();
return;
}

View File

@@ -1,41 +1,123 @@
const dns = require('dns');
const { Netmask } = require('netmask');
const ipaddr = require('ipaddr.js');
const { isIPv6, isIPv4 } = require('net');
const cfg = require('../../../lib/config');
const jwt = require('../../../lib/jwt');
const user = require('../../../lib/db/user');
const ServerUser = require('../../../lib/db/user/User');
const { ERROR, WARNING, INFO, DEBUG } = require('DOUGAL_ROOT/debug')(__filename);
async function authorisedIP (req, res) {
const validIPs = await user.ip({active: true}); // Get all active IP logins
validIPs.forEach( i => i.$block = new Netmask(i.ip) );
validIPs.sort( (a, b) => b.$block.bitmask - a.$block.netmask ); // More specific IPs have precedence
for (const ip of validIPs) {
const block = ip.$block;
if (block.contains(req.ip)) {
function parseIP(ip) {
if (!ip || typeof ip !== 'string') {
WARNING('Invalid IP input:', ip);
return null;
}
// Handle comma-separated X-Forwarded-For (e.g., "87.90.254.127,")
const cleanIp = ip.split(',')[0].trim();
if (!cleanIp) {
WARNING('Empty IP after parsing:', ip);
return null;
}
// Convert IPv6-mapped IPv4 (e.g., ::ffff:127.0.0.1 -> 127.0.0.1)
if (cleanIp.startsWith('::ffff:') && isIPv4(cleanIp.split('::ffff:')[1])) {
return cleanIp.split('::ffff:')[1];
}
return cleanIp;
}
function normalizeCIDR(range) {
if (!range || typeof range !== 'string') {
WARNING('Invalid CIDR range:', range);
return null;
}
// If no /prefix, assume /32 for IPv4 or /128 for IPv6
if (!range.includes('/')) {
try {
const parsed = ipaddr.parse(range);
const prefix = parsed.kind() === 'ipv4' ? 32 : 128;
return `${range}/${prefix}`;
} catch (err) {
WARNING(`Failed to parse bare IP ${range}:`, err.message);
return null;
}
}
return range;
}
async function authorisedIP(req, res) {
const ip = parseIP(req.ip || req.headers['x-forwarded-for'] || req.headers['x-real-ip']);
DEBUG('authorisedIP:', { ip, headers: req.headers }); // Debug
if (!ip) {
WARNING('No valid IP provided:', { ip, headers: req.headers });
return false;
}
let addr;
try {
addr = ipaddr.parse(ip);
} catch (err) {
WARNING('Invalid IP:', ip, err.message);
return false;
}
const validIPs = await user.ip({ active: true }); // Get active IP logins
// Attach parsed CIDR to each IP entry
validIPs.forEach(i => {
const normalized = normalizeCIDR(i.ip);
if (!normalized) {
i.$range = null;
return;
}
try {
const [rangeAddr, prefix] = ipaddr.parseCIDR(normalized);
i.$range = { addr: rangeAddr, prefix };
} catch (err) {
WARNING(`Invalid CIDR range ${i.ip}:`, err.message);
i.$range = null; // Skip invalid ranges
}
});
// Filter out invalid ranges and sort by specificity (descending prefix length)
const validRanges = validIPs.filter(i => i.$range).sort((a, b) => b.$range.prefix - a.$range.prefix);
for (const ipEntry of validRanges) {
const { addr: rangeAddr, prefix } = ipEntry.$range;
try {
if (addr.match(rangeAddr, prefix)) {
const payload = {
...ip,
ip: req.ip,
...ipEntry,
ip,
autologin: true
};
delete payload.$block;
delete payload.$range;
delete payload.hash;
delete payload.active;
jwt.issue(payload, req, res);
return true;
}
} catch (err) {
WARNING(`Error checking range ${ipEntry.ip}:`, err.message);
continue;
}
}
return false;
}
async function authorisedHost (req, res) {
const validHosts = await user.host({active: true}); // Get all active host logins
async function authorisedHost(req, res) {
const ip = parseIP(req.ip || req.headers['x-forwarded-for'] || req.headers['x-real-ip']);
DEBUG('authorisedHost:', { ip, headers: req.headers }); // Debug
if (!ip) {
WARNING('No valid IP for host check:', { ip, headers: req.headers });
return false;
}
const validHosts = await user.host({ active: true });
for (const key in validHosts) {
try {
const ip = await dns.promises.resolve(key);
if (ip == req.ip) {
const resolvedIPs = await dns.promises.resolve(key);
if (resolvedIPs.includes(ip)) {
const payload = {
...validHosts[key],
ip: req.ip,
ip,
autologin: true
};
delete payload.$block;
@@ -45,49 +127,28 @@ async function authorisedHost (req, res) {
return true;
}
} catch (err) {
if (err.code != "ENODATA") {
console.error(err);
if (err.code !== 'ENODATA') {
ERROR(`DNS error for host ${key}:`, err);
}
}
}
return false;
}
// TODO: Check client TLS certificates
// Probably will do this via Nginx with
// ssl_verify_client optional;
// and then putting either of the
// $ssl_client_s_dn or $ssl_client_escaped_cert
// variables into an HTTP header for Node
// to check (naturally, it must be ensured
// that a user cannot just insert the header
// in a request).
async function auth (req, res, next) {
async function auth(req, res, next) {
if (res.headersSent) {
// Nothing to do, this request must have been
// handled already by another middleware.
return;
return; // Handled by another middleware
}
// Check for a valid JWT (already decoded by a previous
// middleware).
// Check for valid JWT
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) {
if (!req.user.autologin && req.user.exp) {
const ttl = req.user.exp - Date.now() / 1000;
if (ttl < cfg.jwt.options.expiresIn / 2) {
const credentials = await ServerUser.fromSQL(null, req.user.id);
if (credentials) {
// Refresh token
payload = Object.assign({}, credentials.toJSON());
jwt.issue(Object.assign({}, credentials.toJSON()), req, res);
}
const payload = Object.assign({}, credentials.toJSON());
jwt.issue(payload, req, res);
}
}
}
@@ -95,19 +156,27 @@ async function auth (req, res, next) {
return;
}
// Check if the IP is known to us
// Check IP and host
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"});
// If *all* else fails, check if the user came with a cookie
// (see https://gitlab.com/wgp/dougal/software/-/issues/335)
if (req.cookies.JWT) {
const token = req.cookies.JWT;
delete req.cookies.JWT;
DEBUG("falling back to cookie-based authentication");
req.user = await jwt.checkValidCredentials({jwt: token});
return await auth(req, res, next);
}
next({ status: 401, message: 'Not authorised' });
}
module.exports = auth;

View File

@@ -5,18 +5,31 @@ const cfg = require("../../../lib/config").jwt;
const getToken = function (req) {
if (req.headers.authorization && req.headers.authorization.split(' ')[0] == 'Bearer') {
return req.headers.authorization.split(' ')[1];
} else if (req.cookies.JWT) {
return req.cookies.JWT;
}
return null;
}
const onExpired = async function (req, err) {
// If it's not too badly expired, let it through
// and hope that a new token will be issued soon.
const elapsed = new Date() - err.inner.expiredAt;
// TODO: Add proper logging
// console.log("Expiry details (elapsed, gracePeriod)", elapsed, cfg.gracePeriod*1000);
if (elapsed < cfg.gracePeriod*1000) {
// console.log("JWT within grace period");
return;
}
throw err;
}
const options = {
secret: cfg.secret,
credentialsRequired: false,
algorithms: ['HS256'],
requestProperty: "user",
getToken
getToken,
onExpired
};
module.exports = expressJWT(options);

View File

@@ -0,0 +1,20 @@
const comparisons = require('../../../../lib/comparisons');
module.exports = async function (req, res, next) {
try {
// const data = await comparisons.groups()
// if (data?.[req.params.group]) {
const data = await comparisons.getGroup(req.params.group);
if (data) {
res.status(200).send(data);
} else {
res.status(404).send({message: "Group does not exist"});
}
return next();
} catch (err) {
next(err);
}
};

View File

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

View File

@@ -0,0 +1,18 @@
const comparisons = require('../../../../lib/comparisons');
module.exports = async function (req, res, next) {
try {
const data = await comparisons.groups()
if (data) {
res.status(200).send(data);
} else {
res.status(204).end();
}
return next();
} catch (err) {
next(err);
}
};

View File

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

View File

@@ -0,0 +1,18 @@
const compression = require('compression');
const compress = compression({
level: 6, // Balance speed vs. ratio (1-9)
threshold: 512, // Compress only if response >512 bytes to avoid overhead on small bundles
filter: (req, res) => { // Ensure bundles are compressed
const accept = req.get("Accept");
if (accept.startsWith("application/vnd.aaltronav.dougal+octet-stream")) return true;
if (accept.includes("json")) return true;
if (accept.startsWith("text/")) return true;
if (accept.startsWith("model/obj")) return true;
// fallback to standard filter function
return compression.filter(req, res)
}
});
module.exports = compress;

View File

@@ -23,9 +23,9 @@ function ifNoneMatch (req, res, next) {
if (cached) {
DEBUG("ETag match. Returning cached response (ETag: %s, If-None-Match: %s) for %s %s",
cached.etag, req.get("If-None-Match"), req.method, req.url);
setHeaders(res, cached.headers);
if (req.method == "GET" || req.method == "HEAD") {
res.status(304).send();
setHeaders(res, cached.headers);
res.status(304).end();
// No next()
} else if (!isIdempotentMethod(req.method)) {
res.status(412).send();

View File

@@ -66,8 +66,18 @@ const rels = [
function invalidateCache (data, cache) {
return new Promise((resolve, reject) => {
if (!data) {
ERROR("invalidateCache called with no data");
return;
}
if (!data.payload) {
ERROR("invalidateCache called without a payload; channel = %s", data.channel);
return;
}
const channel = data.channel;
const project = data.payload.pid ?? data.payload?.new?.pid ?? data.payload?.old?.pid;
const project = data.payload?.pid ?? data.payload?.new?.pid ?? data.payload?.old?.pid;
const operation = data.payload.operation;
const table = data.payload.table;
const fields = { channel, project, operation, table };

View File

@@ -0,0 +1,146 @@
const Busboy = require('busboy');
const { parse } = require('csv-parse/sync');
async function middleware(req, res, next) {
const contentType = req.headers['content-type'] || '';
let csvText = null;
let filename = null;
if (req.params.filename && contentType.startsWith('text/csv')) {
csvText = typeof req.body === 'string' ? req.body : req.body.toString('utf8');
filename = req.params.filename;
processCsv();
} else if (contentType.startsWith('multipart/form-data')) {
const busboy = Busboy({ headers: req.headers });
let found = false;
busboy.on('file', (name, file, info) => {
if (found) {
file.resume();
return;
}
if (info.mimeType === 'text/csv') {
found = true;
filename = info.filename || 'unnamed.csv';
csvText = '';
file.setEncoding('utf8');
file.on('data', (data) => { csvText += data; });
file.on('end', () => {});
} else {
file.resume();
}
});
busboy.on('field', () => {}); // Ignore fields
busboy.on('finish', () => {
if (!found) {
return next();
}
processCsv();
});
req.pipe(busboy);
return;
} else {
return next();
}
function processCsv() {
let records;
try {
records = parse(csvText, {
relax_quotes: true,
quote: '"',
escape: '"',
skip_empty_lines: true,
trim: true
});
} catch (e) {
return res.status(400).json({ error: 'Invalid CSV' });
}
if (!records.length) {
return res.status(400).json({ error: 'Empty CSV' });
}
const headers = records[0].map(h => h.toLowerCase().trim());
const rows = records.slice(1);
let lastDate = null;
let lastTime = null;
const currentDate = new Date().toISOString().slice(0, 10);
const currentTime = new Date().toISOString().slice(11, 19);
const events = [];
for (let row of rows) {
let object = { labels: [] };
for (let k = 0; k < headers.length; k++) {
let key = headers[k];
let val = row[k] ? row[k].trim() : '';
if (!key) continue;
if (['remarks', 'event', 'comment', 'comments', 'text'].includes(key)) {
object.remarks = val;
} else if (key === 'label') {
if (val) object.labels.push(val);
} else if (key === 'labels') {
if (val) object.labels.push(...val.split(';').map(l => l.trim()).filter(l => l));
} else if (key === 'sequence' || key === 'seq') {
if (val) object.sequence = Number(val);
} else if (['point', 'shot', 'shotpoint'].includes(key)) {
if (val) object.point = Number(val);
} else if (key === 'date') {
object.date = val;
} else if (key === 'time') {
object.time = val;
} else if (key === 'timestamp') {
object.timestamp = val;
} else if (key === 'latitude') {
object.latitude = parseFloat(val);
} else if (key === 'longitude') {
object.longitude = parseFloat(val);
}
}
if (!object.remarks) continue;
let useSeqPoint = Number.isFinite(object.sequence) && Number.isFinite(object.point);
let tstamp = null;
if (!useSeqPoint) {
if (object.timestamp) {
tstamp = new Date(object.timestamp);
}
if (!tstamp || isNaN(tstamp.getTime())) {
let dateStr = object.date || lastDate || currentDate;
let timeStr = object.time || lastTime || currentTime;
if (timeStr.length === 5) timeStr += ':00';
let full = `${dateStr}T${timeStr}.000Z`;
tstamp = new Date(full);
if (isNaN(tstamp.getTime())) continue;
}
if (object.date) lastDate = object.date;
if (object.time) lastTime = object.time;
}
let event = {
remarks: object.remarks,
labels: object.labels,
meta: {
author: "*CSVImport*",
"*CSVImport*": {
filename,
tstamp: new Date().toISOString()
}
}
};
if (!isNaN(object.latitude) && !isNaN(object.longitude)) {
event.meta.geometry = {
type: "Point",
coordinates: [object.longitude, object.latitude]
};
}
if (useSeqPoint) {
event.sequence = object.sequence;
event.point = object.point;
} else if (tstamp) {
event.tstamp = tstamp.toISOString();
} else {
continue;
}
events.push(event);
}
req.body = events;
next();
}
}
module.exports = middleware;

View File

@@ -0,0 +1,18 @@
const { event } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
if (req.params.project && req.params.filename) {
await event.unimport(req.params.project, req.params.filename, req.query);
res.status(204).end();
} else {
res.status(400).send({message: "Malformed request"});
}
next();
} catch (err) {
next(err);
}
};

View File

@@ -0,0 +1,6 @@
module.exports = {
csv: require('./csv'),
put: require('./put'),
delete: require('./delete'),
}

View File

@@ -0,0 +1,16 @@
const { event } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
await event.import(req.params.project, payload, req.query);
res.status(200).send(payload);
next();
} catch (err) {
next(err);
}
};

View File

@@ -7,5 +7,6 @@ module.exports = {
put: require('./put'),
patch: require('./patch'),
delete: require('./delete'),
changes: require('./changes')
changes: require('./changes'),
import: require('./import'),
}

View File

@@ -4,6 +4,7 @@ const { gis } = require('../../../../lib/db');
module.exports = async function (req, res, next) {
try {
res.set("Content-Type", "application/geo+json");
res.status(200).send(await gis.project.bbox(req.params.project));
next();
} catch (err) {

View File

@@ -11,6 +11,7 @@ module.exports = {
gis: require('./gis'),
label: require('./label'),
navdata: require('./navdata'),
vessel: require('./vessel'),
queue: require('./queue'),
qc: require('./qc'),
configuration: require('./configuration'),
@@ -20,5 +21,7 @@ module.exports = {
rss: require('./rss'),
etag: require('./etag'),
version: require('./version'),
admin: require('./admin')
admin: require('./admin'),
compress: require('./compress'),
comparisons: require('./comparisons'),
};

Some files were not shown because too many files have changed in this diff Show More