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,70 @@
1
+ import { html, useEffect, useMemo, useState } from '../../../vendor/preact-htm.mjs';
2
+ import { deviceToken } from '../../../js/auth.mjs';
3
+ import { useLocale } from '../../../js/context.mjs';
4
+ import { createAccessRequester } from '../access.mjs';
5
+
6
+ const POLL_MS = 5000;
7
+
8
+ function storage() {
9
+ try {
10
+ return window.localStorage;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function AccessGate({ onGranted }) {
17
+ const { t } = useLocale();
18
+ const access = useMemo(
19
+ () =>
20
+ createAccessRequester({
21
+ fetch: (...args) => window.fetch(...args),
22
+ storage: storage(),
23
+ tokenStore: deviceToken,
24
+ description: t('access.deviceName')
25
+ }),
26
+ []
27
+ );
28
+ const [status, setStatus] = useState(access.hasPendingRequest() ? 'pending' : 'idle');
29
+
30
+ useEffect(() => {
31
+ if (status !== 'pending') {
32
+ return undefined;
33
+ }
34
+ const timer = setInterval(async () => {
35
+ const { state } = await access.poll();
36
+ setStatus(state);
37
+ if (state === 'approved') {
38
+ onGranted();
39
+ }
40
+ }, POLL_MS);
41
+ return () => clearInterval(timer);
42
+ }, [status]);
43
+
44
+ const ask = async () => {
45
+ setStatus('sending');
46
+ const { state } = await access.request();
47
+ setStatus(state);
48
+ };
49
+
50
+ const message = ['idle', 'sending'].includes(status) ? t('access.intro') : t(`access.${status}`);
51
+ return html`
52
+ <section class="panel access" role="alert" aria-labelledby="access-title">
53
+ <h2 id="access-title" class="panel-title">${t('access.title')}</h2>
54
+ <p>${message}</p>
55
+ <div class="sheet-row">
56
+ ${
57
+ status === 'pending'
58
+ ? html`<button type="button" class="big-button" onClick=${() => {
59
+ access.cancel();
60
+ setStatus('idle');
61
+ }}>${t('access.cancel')}</button>`
62
+ : html`<button type="button" class="big-button primary" disabled=${status === 'sending'} onClick=${ask}>
63
+ ${t('access.request')}
64
+ </button>`
65
+ }
66
+ <a class="big-button" href="/admin/#/login">${t('access.signIn')}</a>
67
+ </div>
68
+ </section>
69
+ `;
70
+ }
@@ -0,0 +1,64 @@
1
+ import { html, useEffect, useRef, useState } from '../../../vendor/preact-htm.mjs';
2
+ import { useLocale } from '../../../js/context.mjs';
3
+
4
+ export function CommentDialog({ title, initial = '', onSave, onCancel }) {
5
+ const { t } = useLocale();
6
+ const [text, setText] = useState(initial ?? '');
7
+ const field = useRef(null);
8
+ useEffect(() => field.current?.focus(), []);
9
+
10
+ const submit = (event) => {
11
+ event.preventDefault();
12
+ onSave(text.trim() === '' ? null : text.trim());
13
+ };
14
+
15
+ return html`
16
+ <div class="sheet-backdrop" onClick=${(event) => event.target === event.currentTarget && onCancel()}>
17
+ <form class="sheet" role="dialog" aria-modal="true" aria-labelledby="comment-title" onSubmit=${submit}>
18
+ <h2 id="comment-title">${title}</h2>
19
+ <textarea
20
+ ref=${field}
21
+ rows="4"
22
+ maxlength="10000"
23
+ value=${text}
24
+ aria-label=${t('entry.comment')}
25
+ onInput=${(event) => setText(event.currentTarget.value)}
26
+ ></textarea>
27
+ <div class="sheet-row">
28
+ <button type="button" class="big-button" onClick=${onCancel}>${t('common.cancel')}</button>
29
+ <button type="submit" class="big-button primary">${t('common.save')}</button>
30
+ </div>
31
+ </form>
32
+ </div>
33
+ `;
34
+ }
35
+
36
+ // What was just logged, with the two things one wants right after: undo a
37
+ // mistaken tap, or add a word about it.
38
+ export function Toast({ toast, onUndo, onComment, onClose }) {
39
+ const { t, format } = useLocale();
40
+ if (!toast) {
41
+ return null;
42
+ }
43
+ if (toast.kind === 'error') {
44
+ return html`
45
+ <div class="toast toast-error" role="alert">
46
+ <span class="toast-text">${toast.message}</span>
47
+ <button type="button" class="tool-button" onClick=${onClose}>×</button>
48
+ </div>
49
+ `;
50
+ }
51
+ if (toast.kind === 'undone') {
52
+ return html`<div class="toast" role="status"><span class="toast-text">${t('entry.undone')}</span></div>`;
53
+ }
54
+ const detail = toast.queued
55
+ ? t('entry.loggedQueued')
56
+ : `${t('entry.logged', { time: format.time(toast.time) })}${toast.openedEntry ? ` · ${t('entry.openedPassage')}` : ''}`;
57
+ return html`
58
+ <div class=${toast.queued ? 'toast toast-queued' : 'toast'} role="status">
59
+ <span class="toast-text"><strong>${toast.what}</strong><br />${detail}</span>
60
+ <button type="button" class="big-button" onClick=${onComment}>${t('entry.addComment')}</button>
61
+ <button type="button" class="big-button" onClick=${onUndo}>${t('entry.undo')}</button>
62
+ </div>
63
+ `;
64
+ }
@@ -0,0 +1,106 @@
1
+ import { html, useState } from '../../../vendor/preact-htm.mjs';
2
+ import { useLocale } from '../../../js/context.mjs';
3
+
4
+ const DEPARTURES = new Set(['cast_off', 'anchor_up']);
5
+ export const SAILS = [
6
+ 'main',
7
+ 'genoa',
8
+ 'jib',
9
+ 'staysail',
10
+ 'spinnaker',
11
+ 'gennaker',
12
+ 'code0',
13
+ 'storm_jib'
14
+ ];
15
+
16
+ export function manoeuvreLabel(type, t) {
17
+ const key = `manoeuvre.${type.key}`;
18
+ return t.has(key) ? t(key) : type.label;
19
+ }
20
+
21
+ function SailSheet({ onPick, onClose }) {
22
+ const { t } = useLocale();
23
+ const [other, setOther] = useState('');
24
+ const submitOther = (event) => {
25
+ event.preventDefault();
26
+ if (other.trim()) {
27
+ onPick(other.trim());
28
+ }
29
+ };
30
+ return html`
31
+ <div class="sheet-backdrop" onClick=${(event) => event.target === event.currentTarget && onClose()}>
32
+ <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="sail-title">
33
+ <h2 id="sail-title">${t('entry.chooseSail')}</h2>
34
+ <div class="sail-grid">
35
+ ${SAILS.map(
36
+ (sail) =>
37
+ html`<button type="button" class="big-button" key=${sail} onClick=${() => onPick(sail)}>
38
+ ${t(`sail.${sail}`)}
39
+ </button>`
40
+ )}
41
+ </div>
42
+ <form class="sheet-row" onSubmit=${submitOther}>
43
+ <input
44
+ value=${other}
45
+ maxlength="60"
46
+ placeholder=${t('entry.otherSail')}
47
+ aria-label=${t('entry.otherSail')}
48
+ onInput=${(event) => setOther(event.currentTarget.value)}
49
+ />
50
+ <button type="submit" class="big-button primary" disabled=${!other.trim()}>${t('entry.send')}</button>
51
+ </form>
52
+ <button type="button" class="link-button" onClick=${onClose}>${t('common.cancel')}</button>
53
+ </div>
54
+ </div>
55
+ `;
56
+ }
57
+
58
+ export function ManoeuvrePad({ types, noPassage, busy, onLog }) {
59
+ const { t } = useLocale();
60
+ const [choosingSail, setChoosingSail] = useState(null);
61
+ const enabled = types.filter((type) => type.enabled);
62
+
63
+ const press = (type) => {
64
+ if (type.key === 'sail_change') {
65
+ setChoosingSail(type);
66
+ return;
67
+ }
68
+ onLog({ type: 'manoeuvre', subtype: type.key }, manoeuvreLabel(type, t));
69
+ };
70
+
71
+ return html`
72
+ <section class="panel pad" aria-labelledby="pad-title">
73
+ <h2 id="pad-title" class="panel-title">${t('entry.manoeuvres')}</h2>
74
+ ${noPassage && html`<p class="hint">${t('entry.departHint')}</p>`}
75
+ <div class="pad-grid">
76
+ ${enabled.map((type) => {
77
+ const highlighted = noPassage && DEPARTURES.has(type.key);
78
+ return html`<button
79
+ type="button"
80
+ key=${type.key}
81
+ class=${highlighted ? 'big-button primary' : 'big-button'}
82
+ disabled=${busy}
83
+ onClick=${() => press(type)}
84
+ >
85
+ ${manoeuvreLabel(type, t)}
86
+ </button>`;
87
+ })}
88
+ </div>
89
+ ${
90
+ choosingSail &&
91
+ html`<${SailSheet}
92
+ onClose=${() => setChoosingSail(null)}
93
+ onPick=${(sail) => {
94
+ const type = choosingSail;
95
+ setChoosingSail(null);
96
+ const sailName = t.has(`sail.${sail}`) ? t(`sail.${sail}`) : sail;
97
+ onLog(
98
+ { type: 'manoeuvre', subtype: type.key, payload: { sail } },
99
+ `${manoeuvreLabel(type, t)} (${sailName})`
100
+ );
101
+ }}
102
+ />`
103
+ }
104
+ </section>
105
+ `;
106
+ }
@@ -0,0 +1,34 @@
1
+ import { html, useState } from '../../../vendor/preact-htm.mjs';
2
+ import { useLocale } from '../../../js/context.mjs';
3
+
4
+ export function NotePanel({ busy, onLog }) {
5
+ const { t } = useLocale();
6
+ const [text, setText] = useState('');
7
+
8
+ const submit = async (event) => {
9
+ event.preventDefault();
10
+ const comment = text.trim();
11
+ if (!comment) {
12
+ return;
13
+ }
14
+ if (await onLog({ type: 'text_annotation', comment }, t('entry.note'))) {
15
+ setText('');
16
+ }
17
+ };
18
+
19
+ return html`
20
+ <form class="note-form" onSubmit=${submit}>
21
+ <textarea
22
+ value=${text}
23
+ rows="5"
24
+ maxlength="10000"
25
+ placeholder=${t('entry.notePlaceholder')}
26
+ aria-label=${t('entry.note')}
27
+ onInput=${(event) => setText(event.currentTarget.value)}
28
+ ></textarea>
29
+ <button type="submit" class="big-button primary" disabled=${busy || !text.trim()}>
30
+ ${t('entry.send')}
31
+ </button>
32
+ </form>
33
+ `;
34
+ }
@@ -0,0 +1,113 @@
1
+ import { html, useState } from '../../../vendor/preact-htm.mjs';
2
+ import { fetchAll } from '../../../js/api.mjs';
3
+ import { useLocale, usePolling } from '../../../js/context.mjs';
4
+ import { EventRemark } from '../../../js/components/Timeline.mjs';
5
+
6
+ const REFRESH_MS = 30 * 1000;
7
+ const SHOWN = 15;
8
+
9
+ const CLIENT_TYPES = new Set(['manoeuvre', 'text_annotation', 'handwritten_annotation']);
10
+
11
+ function queuedLabel(body, t, manoeuvreLabels) {
12
+ if (body.type === 'manoeuvre') {
13
+ const key = `manoeuvre.${body.subtype}`;
14
+ return t.has(key) ? t(key) : (manoeuvreLabels[body.subtype] ?? body.subtype);
15
+ }
16
+ return body.type === 'handwritten_annotation' ? t('event.handwritten') : body.comment;
17
+ }
18
+
19
+ function failureReason(error, t) {
20
+ return error.code === 'no_passage' ? t('entry.noPassageError') : error.message;
21
+ }
22
+
23
+ export function RecentList({
24
+ entryId,
25
+ version,
26
+ queue,
27
+ manoeuvreLabels,
28
+ onDelete,
29
+ onDiscard,
30
+ onComment
31
+ }) {
32
+ const { t, format } = useLocale();
33
+ const [events, setEvents] = useState([]);
34
+
35
+ usePolling(
36
+ (isCurrent) => {
37
+ if (entryId === null) {
38
+ setEvents([]);
39
+ return;
40
+ }
41
+ fetchAll(`/entries/${entryId}/events`)
42
+ .then((items) => isCurrent() && setEvents(items.slice(-SHOWN).reverse()))
43
+ .catch(() => {
44
+ // Kept as last seen; the header already tells when the server is away.
45
+ });
46
+ },
47
+ REFRESH_MS,
48
+ [entryId, version]
49
+ );
50
+
51
+ const posts = (items) => items.filter((item) => item.method === 'POST');
52
+ const failed = posts(queue.failed);
53
+ const pending = posts(queue.pending).reverse();
54
+ const nothing = failed.length + pending.length + events.length === 0;
55
+
56
+ return html`
57
+ <section class="panel recent" aria-labelledby="recent-title">
58
+ <h2 id="recent-title" class="panel-title">${t('entry.recent')}</h2>
59
+ ${nothing && html`<p class="hint">${t('entry.recentEmpty')}</p>`}
60
+ <ul class="recent-list">
61
+ ${failed.map(
62
+ (item) => html`<li key=${item.ref} class="recent-item recent-failed">
63
+ <span class="recent-time">${format.time(item.body.time)}</span>
64
+ <span class="recent-what">
65
+ <strong>${queuedLabel(item.body, t, manoeuvreLabels)}</strong>
66
+ <br />${t('entry.failed', { reason: failureReason(item.error, t) })}
67
+ </span>
68
+ <span class="recent-actions">
69
+ <button type="button" class="tool-button" onClick=${() => onDiscard(item.ref)}>
70
+ ${t('entry.discard')}
71
+ </button>
72
+ </span>
73
+ </li>`
74
+ )}
75
+ ${pending.map(
76
+ (item) => html`<li key=${item.ref} class="recent-item recent-pending">
77
+ <span class="recent-time">${format.time(item.body.time)}</span>
78
+ <span class="recent-what">
79
+ <strong>${queuedLabel(item.body, t, manoeuvreLabels)}</strong>
80
+ ${item.body.comment && item.body.type !== 'text_annotation' ? ` — ${item.body.comment}` : ''}
81
+ <br /><span class="muted">${t('entry.pending')}</span>
82
+ </span>
83
+ <span class="recent-actions">
84
+ <button type="button" class="tool-button" onClick=${() => onDiscard(item.ref)}>
85
+ ${t('entry.discard')}
86
+ </button>
87
+ </span>
88
+ </li>`
89
+ )}
90
+ ${events.map(
91
+ (event) => html`<li key=${event.id} class=${`recent-item event-${event.type}`}>
92
+ <span class="recent-time">${format.time(event.time)}</span>
93
+ <span class="recent-what">
94
+ <${EventRemark} event=${event} manoeuvreLabels=${manoeuvreLabels} />
95
+ </span>
96
+ <span class="recent-actions">
97
+ <button type="button" class="tool-button" onClick=${() => onComment(event)}>
98
+ ${event.type === 'text_annotation' ? t('common.edit') : t('entry.comment')}
99
+ </button>
100
+ ${
101
+ event.source === 'manual' &&
102
+ CLIENT_TYPES.has(event.type) &&
103
+ html`<button type="button" class="tool-button danger" onClick=${() => onDelete(event)}>
104
+ ${t('entry.delete')}
105
+ </button>`
106
+ }
107
+ </span>
108
+ </li>`
109
+ )}
110
+ </ul>
111
+ </section>
112
+ `;
113
+ }
@@ -0,0 +1,181 @@
1
+ import { html, useEffect, useRef, useState } from '../../../vendor/preact-htm.mjs';
2
+ import { useLocale } from '../../../js/context.mjs';
3
+ import { createStrokeRecorder, strokeWidth } from '../strokes.mjs';
4
+
5
+ const BASE_WIDTH = 2.5;
6
+
7
+ function drawStroke(context, stroke) {
8
+ const { points } = stroke;
9
+ if (points.length === 1) {
10
+ const [point] = points;
11
+ context.beginPath();
12
+ context.arc(point.x, point.y, strokeWidth(BASE_WIDTH, point.pressure) / 2, 0, Math.PI * 2);
13
+ context.fill();
14
+ return;
15
+ }
16
+ for (let i = 1; i < points.length; i += 1) {
17
+ const from = points[i - 1];
18
+ const to = points[i];
19
+ context.lineWidth = strokeWidth(BASE_WIDTH, to.pressure);
20
+ context.beginPath();
21
+ context.moveTo(from.x, from.y);
22
+ context.lineTo(to.x, to.y);
23
+ context.stroke();
24
+ }
25
+ }
26
+
27
+ export function SketchPanel({ busy, onLog }) {
28
+ const { t } = useLocale();
29
+ const canvas = useRef(null);
30
+ const recorder = useRef(createStrokeRecorder());
31
+ const activePointer = useRef(null);
32
+ // Once a pen has touched the canvas, fingers are the palm resting on it.
33
+ const penSeen = useRef(false);
34
+ const [strokeCount, setStrokeCount] = useState(0);
35
+ const [comment, setComment] = useState('');
36
+
37
+ function context() {
38
+ const element = canvas.current;
39
+ const drawing = element.getContext('2d');
40
+ drawing.lineCap = 'round';
41
+ drawing.lineJoin = 'round';
42
+ const colour = getComputedStyle(element).color;
43
+ drawing.strokeStyle = colour;
44
+ drawing.fillStyle = colour;
45
+ return drawing;
46
+ }
47
+
48
+ function redraw() {
49
+ const element = canvas.current;
50
+ if (!element) {
51
+ return;
52
+ }
53
+ const ratio = window.devicePixelRatio || 1;
54
+ const { width, height } = element.getBoundingClientRect();
55
+ element.width = Math.round(width * ratio);
56
+ element.height = Math.round(height * ratio);
57
+ const drawing = context();
58
+ drawing.setTransform(ratio, 0, 0, ratio, 0, 0);
59
+ drawing.clearRect(0, 0, width, height);
60
+ recorder.current.strokes.forEach((stroke) => drawStroke(drawing, stroke));
61
+ }
62
+
63
+ useEffect(() => {
64
+ redraw();
65
+ const observer = new ResizeObserver(redraw);
66
+ observer.observe(canvas.current);
67
+ return () => observer.disconnect();
68
+ }, []);
69
+
70
+ const locate = (event) => {
71
+ const box = canvas.current.getBoundingClientRect();
72
+ return [event.clientX - box.left, event.clientY - box.top];
73
+ };
74
+ const pressureOf = (event) => (event.pointerType === 'pen' ? event.pressure : undefined);
75
+
76
+ const onPointerDown = (event) => {
77
+ if (event.pointerType === 'pen') {
78
+ penSeen.current = true;
79
+ } else if (event.pointerType === 'touch' && penSeen.current) {
80
+ return;
81
+ }
82
+ if (activePointer.current !== null || busy) {
83
+ return;
84
+ }
85
+ event.preventDefault();
86
+ canvas.current.setPointerCapture(event.pointerId);
87
+ activePointer.current = event.pointerId;
88
+ const [x, y] = locate(event);
89
+ const stroke = recorder.current.begin(x, y, event.timeStamp, pressureOf(event));
90
+ const drawing = context();
91
+ drawing.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0);
92
+ drawStroke(drawing, stroke);
93
+ };
94
+
95
+ const onPointerMove = (event) => {
96
+ if (event.pointerId !== activePointer.current) {
97
+ return;
98
+ }
99
+ const samples = event.getCoalescedEvents?.() ?? [];
100
+ const drawing = context();
101
+ drawing.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0);
102
+ for (const sample of samples.length > 0 ? samples : [event]) {
103
+ const [x, y] = locate(sample);
104
+ const stroke = recorder.current.extend(x, y, sample.timeStamp, pressureOf(sample));
105
+ const { points } = stroke;
106
+ if (points.length > 1) {
107
+ drawStroke(drawing, { points: points.slice(-2) });
108
+ }
109
+ }
110
+ };
111
+
112
+ const onPointerUp = (event) => {
113
+ if (event.pointerId !== activePointer.current) {
114
+ return;
115
+ }
116
+ activePointer.current = null;
117
+ recorder.current.end();
118
+ setStrokeCount(recorder.current.strokes.length);
119
+ };
120
+
121
+ const undo = () => {
122
+ recorder.current.undo();
123
+ setStrokeCount(recorder.current.strokes.length);
124
+ redraw();
125
+ };
126
+
127
+ const clear = () => {
128
+ recorder.current.clear();
129
+ setStrokeCount(0);
130
+ setComment('');
131
+ redraw();
132
+ };
133
+
134
+ const send = async () => {
135
+ const box = canvas.current.getBoundingClientRect();
136
+ const body = { type: 'handwritten_annotation', payload: recorder.current.payload(box) };
137
+ if (comment.trim()) {
138
+ body.comment = comment.trim();
139
+ }
140
+ if (await onLog(body, t('event.handwritten'))) {
141
+ clear();
142
+ }
143
+ };
144
+
145
+ return html`
146
+ <div class="sketch">
147
+ <div class="sketch-surface">
148
+ <canvas
149
+ ref=${canvas}
150
+ class="sketch-canvas"
151
+ role="img"
152
+ aria-label=${t('entry.sketchArea')}
153
+ onPointerDown=${onPointerDown}
154
+ onPointerMove=${onPointerMove}
155
+ onPointerUp=${onPointerUp}
156
+ onPointerCancel=${onPointerUp}
157
+ ></canvas>
158
+ ${strokeCount === 0 && html`<span class="sketch-hint" aria-hidden="true">${t('entry.sketchHint')}</span>`}
159
+ </div>
160
+ <input
161
+ class="sketch-comment"
162
+ value=${comment}
163
+ maxlength="10000"
164
+ placeholder=${t('entry.comment')}
165
+ aria-label=${t('entry.comment')}
166
+ onInput=${(event) => setComment(event.currentTarget.value)}
167
+ />
168
+ <div class="sketch-actions">
169
+ <button type="button" class="tool-button" disabled=${strokeCount === 0} onClick=${undo}>
170
+ ${t('entry.sketchUndo')}
171
+ </button>
172
+ <button type="button" class="tool-button" disabled=${strokeCount === 0} onClick=${clear}>
173
+ ${t('entry.sketchClear')}
174
+ </button>
175
+ <button type="button" class="big-button primary" disabled=${busy || strokeCount === 0} onClick=${send}>
176
+ ${t('entry.send')}
177
+ </button>
178
+ </div>
179
+ </div>
180
+ `;
181
+ }
@@ -0,0 +1,69 @@
1
+ import { html } from '../../../vendor/preact-htm.mjs';
2
+ import { useLocale } from '../../../js/context.mjs';
3
+ import { elapsedSeconds } from '../../../js/components/common.mjs';
4
+ import { fallbackMessage } from '../../../js/status.mjs';
5
+
6
+ function motionLabel(state, t) {
7
+ if (!state) {
8
+ return t('common.loading');
9
+ }
10
+ if (state.motion === 'underway') {
11
+ if (state.propulsion === 'engine') {
12
+ return t('status.underwayEngine');
13
+ }
14
+ return state.propulsion === 'sail' ? t('status.underwaySail') : t('status.underway');
15
+ }
16
+ return state.motion === 'stopped' ? t('status.stopped') : t('status.unknown');
17
+ }
18
+
19
+ // A passage opened by casting off stays stopped from its start until the
20
+ // vessel moves.
21
+ function passageLine(entry, t, format) {
22
+ if (!entry) {
23
+ return t('entry.noPassage');
24
+ }
25
+ if (entry.stoppedSince) {
26
+ const waiting = entry.openedByEventId !== null && entry.stoppedSince === entry.startTime;
27
+ return waiting
28
+ ? t('entry.waitingToLeave', { time: format.time(entry.stoppedSince) })
29
+ : t('passage.stoppedSince', { time: format.time(entry.stoppedSince) });
30
+ }
31
+ return t('entry.passageFrom', {
32
+ place: entry.startPlaceName ?? t('place.unknown'),
33
+ duration: format.duration(elapsedSeconds(entry))
34
+ });
35
+ }
36
+
37
+ export function StatusHeader({ state, entry, online, queued, night, onToggleNight }) {
38
+ const { t, format } = useLocale();
39
+ const connection = online ? t('entry.online') : t('entry.offline');
40
+ return html`
41
+ <header class="entry-header">
42
+ <div class="entry-status">
43
+ <strong class=${`entry-motion motion-${state?.motion ?? 'unknown'}`}>
44
+ ${motionLabel(state, t)}
45
+ </strong>
46
+ <span class="entry-passage">${state ? passageLine(entry, t, format) : ''}</span>
47
+ ${
48
+ state?.detection === 'fallback' &&
49
+ html`<span class="entry-fallback" title=${fallbackMessage(state.stateIssue, t, format)}>⚠</span>`
50
+ }
51
+ </div>
52
+ <div class="entry-tools">
53
+ <span class=${online ? 'connection connection-online' : 'connection connection-offline'}>
54
+ <span class="connection-dot" aria-hidden="true"></span>
55
+ ${queued > 0 ? `${connection} · ${t('entry.queued', { count: queued })}` : connection}
56
+ </span>
57
+ <button
58
+ type="button"
59
+ class="tool-button"
60
+ aria-pressed=${night ? 'true' : 'false'}
61
+ onClick=${onToggleNight}
62
+ >
63
+ ${t('entry.night')}
64
+ </button>
65
+ <a class="tool-button" href="../">${t('entry.logbook')}</a>
66
+ </div>
67
+ </header>
68
+ `;
69
+ }