signalk-chiplog 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +290 -0
  4. package/index.js +327 -0
  5. package/lib/api.js +427 -0
  6. package/lib/database.js +264 -0
  7. package/lib/detection.js +407 -0
  8. package/lib/entries.js +345 -0
  9. package/lib/errors.js +22 -0
  10. package/lib/event-watcher.js +332 -0
  11. package/lib/events.js +287 -0
  12. package/lib/export.js +220 -0
  13. package/lib/formats.js +215 -0
  14. package/lib/manoeuvre-types.js +84 -0
  15. package/lib/observation-recorder.js +152 -0
  16. package/lib/place-names.js +208 -0
  17. package/lib/places.js +128 -0
  18. package/lib/propulsion-detector.js +156 -0
  19. package/lib/propulsion.js +75 -0
  20. package/lib/rows.js +15 -0
  21. package/lib/track-recorder.js +149 -0
  22. package/lib/track.js +58 -0
  23. package/lib/usb-scheduler.js +131 -0
  24. package/lib/validation.js +105 -0
  25. package/package.json +67 -0
  26. package/public/app.css +603 -0
  27. package/public/entry/entry.css +528 -0
  28. package/public/entry/icons/apple-touch-icon.png +0 -0
  29. package/public/entry/icons/icon-192.png +0 -0
  30. package/public/entry/icons/icon-512.png +0 -0
  31. package/public/entry/index.html +22 -0
  32. package/public/entry/js/access.mjs +116 -0
  33. package/public/entry/js/clock.mjs +29 -0
  34. package/public/entry/js/components/AccessGate.mjs +70 -0
  35. package/public/entry/js/components/Dialogs.mjs +64 -0
  36. package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
  37. package/public/entry/js/components/NotePanel.mjs +34 -0
  38. package/public/entry/js/components/RecentList.mjs +113 -0
  39. package/public/entry/js/components/SketchPanel.mjs +181 -0
  40. package/public/entry/js/components/StatusHeader.mjs +69 -0
  41. package/public/entry/js/journal.mjs +139 -0
  42. package/public/entry/js/main.mjs +360 -0
  43. package/public/entry/js/outbox.mjs +124 -0
  44. package/public/entry/js/strokes.mjs +81 -0
  45. package/public/entry/manifest.webmanifest +26 -0
  46. package/public/entry/sw.js +75 -0
  47. package/public/icon.svg +7 -0
  48. package/public/index.html +18 -0
  49. package/public/js/api.mjs +85 -0
  50. package/public/js/auth.mjs +41 -0
  51. package/public/js/components/ExportView.mjs +182 -0
  52. package/public/js/components/LogView.mjs +132 -0
  53. package/public/js/components/PassageView.mjs +343 -0
  54. package/public/js/components/PropulsionStrip.mjs +60 -0
  55. package/public/js/components/StatusBar.mjs +55 -0
  56. package/public/js/components/Timeline.mjs +226 -0
  57. package/public/js/components/TrackMap.mjs +85 -0
  58. package/public/js/components/common.mjs +70 -0
  59. package/public/js/context.mjs +56 -0
  60. package/public/js/days.mjs +56 -0
  61. package/public/js/format.mjs +84 -0
  62. package/public/js/i18n.mjs +480 -0
  63. package/public/js/ids.mjs +10 -0
  64. package/public/js/main.mjs +48 -0
  65. package/public/js/status.mjs +21 -0
  66. package/public/vendor/htm-LICENSE +202 -0
  67. package/public/vendor/leaflet/LICENSE +26 -0
  68. package/public/vendor/leaflet/images/layers-2x.png +0 -0
  69. package/public/vendor/leaflet/images/layers.png +0 -0
  70. package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
  71. package/public/vendor/leaflet/images/marker-icon.png +0 -0
  72. package/public/vendor/leaflet/images/marker-shadow.png +0 -0
  73. package/public/vendor/leaflet/leaflet.css +661 -0
  74. package/public/vendor/leaflet/leaflet.js +6 -0
  75. package/public/vendor/preact-LICENSE +21 -0
  76. package/public/vendor/preact-htm.mjs +1 -0
@@ -0,0 +1,85 @@
1
+ import { html, useEffect, useRef } from '../../vendor/preact-htm.mjs';
2
+ import { useLocale } from '../context.mjs';
3
+
4
+ const TRACK_COLOUR = '#e8590c';
5
+ const DEPARTURE_COLOUR = '#2b8a3e';
6
+ const ARRIVAL_COLOUR = '#c92a2a';
7
+
8
+ // Signal K serves every page with `Referrer-Policy: no-referrer`, and the
9
+ // OpenStreetMap tile servers answer a request without a Referer with an
10
+ // "Access blocked" tile. Send the origin only, as browsers do by default.
11
+ const TILE_REFERRER_POLICY = 'strict-origin-when-cross-origin';
12
+
13
+ // Leaflet renders a string tooltip as HTML; place names are typed by the crew.
14
+ function textTooltip(text) {
15
+ const element = document.createElement('span');
16
+ element.textContent = text;
17
+ return element;
18
+ }
19
+
20
+ export function TrackMap({ track, entry }) {
21
+ const { t } = useLocale();
22
+ const container = useRef(null);
23
+ const map = useRef(null);
24
+ const overlay = useRef(null);
25
+ const fitted = useRef(false);
26
+
27
+ useEffect(() => {
28
+ const instance = L.map(container.current, { scrollWheelZoom: false });
29
+ const streets = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
30
+ maxZoom: 19,
31
+ referrerPolicy: TILE_REFERRER_POLICY,
32
+ attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
33
+ }).addTo(instance);
34
+ const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
35
+ maxZoom: 18,
36
+ referrerPolicy: TILE_REFERRER_POLICY,
37
+ attribution: '© <a href="https://www.openseamap.org">OpenSeaMap</a>'
38
+ }).addTo(instance);
39
+ L.control.layers({ OpenStreetMap: streets }, { [t('map.seamarks')]: seamarks }).addTo(instance);
40
+ map.current = instance;
41
+ return () => {
42
+ instance.remove();
43
+ map.current = null;
44
+ };
45
+ }, []);
46
+
47
+ // Redrawn on each refresh of a passage in progress, without resetting the
48
+ // view the reader may have zoomed to.
49
+ useEffect(() => {
50
+ const instance = map.current;
51
+ overlay.current?.remove();
52
+ const group = L.featureGroup();
53
+
54
+ if (track?.geometry) {
55
+ L.geoJSON(track, { style: { color: TRACK_COLOUR, weight: 3, opacity: 0.9 } }).addTo(group);
56
+ }
57
+ const ends = [[entry.startPosition, entry.startPlaceName, DEPARTURE_COLOUR]];
58
+ if (entry.endTime) {
59
+ ends.push([entry.endPosition, entry.endPlaceName, ARRIVAL_COLOUR]);
60
+ }
61
+ for (const [position, name, colour] of ends) {
62
+ if (position) {
63
+ L.circleMarker([position.lat, position.lon], {
64
+ radius: 7,
65
+ color: '#ffffff',
66
+ weight: 2,
67
+ fillColor: colour,
68
+ fillOpacity: 1
69
+ })
70
+ .bindTooltip(textTooltip(name ?? t('place.unknown')))
71
+ .addTo(group);
72
+ }
73
+ }
74
+
75
+ group.addTo(instance);
76
+ overlay.current = group;
77
+ const bounds = group.getBounds();
78
+ if (!fitted.current && bounds.isValid()) {
79
+ instance.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
80
+ fitted.current = true;
81
+ }
82
+ }, [track, entry]);
83
+
84
+ return html`<div class="track-map" ref=${container}></div>`;
85
+ }
@@ -0,0 +1,70 @@
1
+ import { html } from '../../vendor/preact-htm.mjs';
2
+ import { useLocale } from '../context.mjs';
3
+
4
+ export function ErrorNotice({ error, onRetry }) {
5
+ const { t } = useLocale();
6
+ if (!error) {
7
+ return null;
8
+ }
9
+ let message;
10
+ if (error.code === 'forbidden') {
11
+ message = html`${t('error.forbidden')} <a href="/admin/">${t('error.signIn')}</a>`;
12
+ } else if (error.code === 'plugin_not_started') {
13
+ message = t('error.notRunning');
14
+ } else if (error.code === 'network') {
15
+ message = t('error.network');
16
+ } else {
17
+ message = t('error.generic', { message: error.message });
18
+ }
19
+ return html`
20
+ <div class="notice notice-error" role="alert">
21
+ <p>${message}</p>
22
+ ${onRetry && html`<button type="button" onClick=${onRetry}>${t('common.retry')}</button>`}
23
+ </div>
24
+ `;
25
+ }
26
+
27
+ export function Loading() {
28
+ const { t } = useLocale();
29
+ return html`<p class="loading">${t('common.loading')}</p>`;
30
+ }
31
+
32
+ export function PlaceName({ name, pending }) {
33
+ const { t } = useLocale();
34
+ return html`
35
+ <span
36
+ class=${pending ? 'place place-pending' : 'place'}
37
+ title=${pending ? t('place.pending') : undefined}
38
+ >
39
+ ${name ?? t('place.unknown')}
40
+ </span>
41
+ `;
42
+ }
43
+
44
+ export function passageTitle(entry, t) {
45
+ const from = entry.startPlaceName ?? t('place.unknown');
46
+ return entry.endTime ? `${from} → ${entry.endPlaceName ?? t('place.unknown')}` : `${from} → …`;
47
+ }
48
+
49
+ export function EngineSailBar({ engine, sail }) {
50
+ const { t, format } = useLocale();
51
+ const total = engine + sail;
52
+ if (total <= 0) {
53
+ return null;
54
+ }
55
+ const enginePercent = (engine / total) * 100;
56
+ return html`
57
+ <div
58
+ class="engine-sail"
59
+ title=${`${t('passage.engine')} ${format.duration(engine)} · ${t('passage.sail')} ${format.duration(sail)}`}
60
+ >
61
+ <span class="engine-sail-engine" style=${`width:${enginePercent}%`}></span>
62
+ <span class="engine-sail-sail" style=${`width:${100 - enginePercent}%`}></span>
63
+ </div>
64
+ `;
65
+ }
66
+
67
+ export function elapsedSeconds(entry, now = Date.now()) {
68
+ const end = entry.endTime ? Date.parse(entry.endTime) : now;
69
+ return Math.max(0, (end - Date.parse(entry.startTime)) / 1000);
70
+ }
@@ -0,0 +1,56 @@
1
+ import { createContext, useContext, useEffect, useState } from '../vendor/preact-htm.mjs';
2
+ import { createFormatter } from './format.mjs';
3
+ import { createTranslator } from './i18n.mjs';
4
+
5
+ const LocaleContext = createContext(null);
6
+
7
+ export const LocaleProvider = LocaleContext.Provider;
8
+
9
+ export function createLocale(language) {
10
+ const t = createTranslator(language);
11
+ const format = createFormatter({
12
+ locale: language,
13
+ units: { knots: t('unit.knots'), nauticalMiles: t('unit.nauticalMiles') }
14
+ });
15
+ return { language, t, format };
16
+ }
17
+
18
+ export function useLocale() {
19
+ return useContext(LocaleContext);
20
+ }
21
+
22
+ function parseRoute(hash) {
23
+ const passage = hash.match(/^#\/passages\/(\d+)$/);
24
+ if (passage) {
25
+ return { name: 'passage', id: Number(passage[1]) };
26
+ }
27
+ return hash === '#/export' ? { name: 'export' } : { name: 'log' };
28
+ }
29
+
30
+ export function useRoute() {
31
+ const [route, setRoute] = useState(() => parseRoute(location.hash));
32
+ useEffect(() => {
33
+ const onChange = () => {
34
+ setRoute(parseRoute(location.hash));
35
+ window.scrollTo(0, 0);
36
+ };
37
+ window.addEventListener('hashchange', onChange);
38
+ return () => window.removeEventListener('hashchange', onChange);
39
+ }, []);
40
+ return route;
41
+ }
42
+
43
+ // Runs `load` now and every `intervalMs`, ignoring results that arrive after
44
+ // the component is gone.
45
+ export function usePolling(load, intervalMs, dependencies = []) {
46
+ useEffect(() => {
47
+ let active = true;
48
+ const run = () => load(() => active);
49
+ run();
50
+ const timer = intervalMs ? setInterval(run, intervalMs) : null;
51
+ return () => {
52
+ active = false;
53
+ clearInterval(timer);
54
+ };
55
+ }, dependencies);
56
+ }
@@ -0,0 +1,56 @@
1
+ // Grouping passages by day is a display concern (SPEC §3.2): days are local to
2
+ // the device showing the log, and a passage appears on every day it touches.
3
+
4
+ function localMidnight(date) {
5
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
6
+ }
7
+
8
+ export function dayKey(date) {
9
+ const month = String(date.getMonth() + 1).padStart(2, '0');
10
+ const day = String(date.getDate()).padStart(2, '0');
11
+ return `${date.getFullYear()}-${month}-${day}`;
12
+ }
13
+
14
+ export function groupByDay(entries, now = Date.now()) {
15
+ const days = new Map();
16
+
17
+ for (const entry of entries) {
18
+ const start = new Date(entry.startTime);
19
+ // The last instant the passage covers: one ending exactly at midnight does
20
+ // not spill onto the next day. An open passage runs up to now.
21
+ const last = entry.endTime
22
+ ? new Date(Math.max(start.getTime(), Date.parse(entry.endTime) - 1))
23
+ : new Date(Math.max(now, start.getTime()));
24
+ const startKey = dayKey(start);
25
+ const endKey = dayKey(last);
26
+
27
+ // Stepping the calendar date rather than adding 24 hours keeps days right
28
+ // across daylight saving changes.
29
+ for (
30
+ const cursor = localMidnight(start);
31
+ cursor <= last;
32
+ cursor.setDate(cursor.getDate() + 1)
33
+ ) {
34
+ const key = dayKey(cursor);
35
+ if (!days.has(key)) {
36
+ days.set(key, { key, date: new Date(cursor), items: [], distance: 0 });
37
+ }
38
+ const day = days.get(key);
39
+ day.items.push({
40
+ entry,
41
+ continuesFromPreviousDay: key !== startKey,
42
+ continuesNextDay: key !== endKey
43
+ });
44
+ // A passage's distance counts on the day it started; the log holds no
45
+ // per-day split of it.
46
+ if (key === startKey) {
47
+ day.distance += entry.distance ?? 0;
48
+ }
49
+ }
50
+ }
51
+
52
+ for (const day of days.values()) {
53
+ day.items.sort((a, b) => b.entry.startTime.localeCompare(a.entry.startTime));
54
+ }
55
+ return [...days.values()].sort((a, b) => b.key.localeCompare(a.key));
56
+ }
@@ -0,0 +1,84 @@
1
+ // Display formatting. The API speaks Signal K SI units; conversion to what a
2
+ // sailor reads happens here, and only here, on the webapp side.
3
+
4
+ const KNOTS_PER_MPS = 3600 / 1852;
5
+ const METRES_PER_NM = 1852;
6
+
7
+ function wrap360(degrees) {
8
+ return ((degrees % 360) + 360) % 360;
9
+ }
10
+
11
+ function toDegrees(radians) {
12
+ return (radians * 180) / Math.PI;
13
+ }
14
+
15
+ export function createFormatter({ locale, units }) {
16
+ const number = (digits) =>
17
+ new Intl.NumberFormat(locale, { minimumFractionDigits: digits, maximumFractionDigits: digits });
18
+ const oneDecimal = number(1);
19
+ const noDecimal = number(0);
20
+ const missing = (value) => value === null || value === undefined || Number.isNaN(value);
21
+
22
+ const dayFormat = new Intl.DateTimeFormat(locale, {
23
+ weekday: 'long',
24
+ day: 'numeric',
25
+ month: 'long',
26
+ year: 'numeric'
27
+ });
28
+ const shortDateFormat = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' });
29
+ const timeFormat = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' });
30
+
31
+ function sexagesimal(value, positive, negative, width) {
32
+ const absolute = Math.abs(value);
33
+ let degrees = Math.floor(absolute);
34
+ let minutes = Number(((absolute - degrees) * 60).toFixed(2));
35
+ if (minutes >= 60) {
36
+ degrees += 1;
37
+ minutes = 0;
38
+ }
39
+ const hemisphere = value >= 0 ? positive : negative;
40
+ return `${String(degrees).padStart(width, '0')}°${minutes.toFixed(2).padStart(5, '0')}′${hemisphere}`;
41
+ }
42
+
43
+ return {
44
+ speed: (mps) =>
45
+ missing(mps) ? '' : `${oneDecimal.format(mps * KNOTS_PER_MPS)} ${units.knots}`,
46
+ distance: (metres) =>
47
+ missing(metres) ? '' : `${oneDecimal.format(metres / METRES_PER_NM)} ${units.nauticalMiles}`,
48
+ bearing: (radians) => {
49
+ if (missing(radians)) {
50
+ return '';
51
+ }
52
+ const degrees = Math.round(wrap360(toDegrees(radians))) % 360;
53
+ return `${String(degrees).padStart(3, '0')}°`;
54
+ },
55
+ // Apparent wind angle: negative to port, positive to starboard.
56
+ angle: (radians) => {
57
+ if (missing(radians)) {
58
+ return '';
59
+ }
60
+ const degrees = Math.round(wrap360(toDegrees(radians) + 180) - 180);
61
+ return `${degrees > 0 ? '+' : ''}${degrees}°`;
62
+ },
63
+ depth: (metres) => (missing(metres) ? '' : `${oneDecimal.format(metres)} m`),
64
+ pressure: (pascals) => (missing(pascals) ? '' : `${noDecimal.format(pascals / 100)} hPa`),
65
+ temperature: (kelvin) => (missing(kelvin) ? '' : `${oneDecimal.format(kelvin - 273.15)} °C`),
66
+ duration: (seconds) => {
67
+ if (missing(seconds)) {
68
+ return '';
69
+ }
70
+ const minutes = Math.round(seconds / 60);
71
+ if (minutes < 60) {
72
+ return `${minutes} min`;
73
+ }
74
+ return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, '0')}`;
75
+ },
76
+ position: (position) =>
77
+ position
78
+ ? `${sexagesimal(position.lat, 'N', 'S', 2)} ${sexagesimal(position.lon, 'E', 'W', 3)}`
79
+ : '',
80
+ day: (date) => dayFormat.format(date),
81
+ shortDate: (value) => shortDateFormat.format(new Date(value)),
82
+ time: (value) => timeFormat.format(new Date(value))
83
+ };
84
+ }