signalk-chiplog 2.14.0 → 2.15.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.15.0] - 2026-10-30
10
+
11
+ ### Added
12
+
13
+ - **Delete an instrument reading.** An administrator can delete any reading from a passage's journal (trash icon on its
14
+ row), including those taken at departure and arrival. Any log line, automatic ones included, can now be deleted from
15
+ the journal too.
16
+ - **Link to the passage in the summary emails.** A new **Server address for the summary link** setting; each summary
17
+ then ends with a link that opens the passage in the webapp. Without it, no link.
18
+ - **Icon buttons in the journal**: edit and delete are a pencil and a bin instead of words.
19
+ - **Hour scale on the tide graph**: the passage page's curve is graduated every three hours, in the logbook's time zone.
20
+
9
21
  ## [2.14.0] - 2026-10-25
10
22
 
11
23
  ### Added
@@ -523,7 +535,8 @@ First release.
523
535
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
524
536
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
525
537
 
526
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.14.0...HEAD
538
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.15.0...HEAD
539
+ [2.15.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.14.0...v2.15.0
527
540
  [2.14.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.13.0...v2.14.0
528
541
  [2.13.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...v2.13.0
529
542
  [2.12.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...v2.12.0
package/README.md CHANGED
@@ -217,12 +217,13 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
217
217
  counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and current noted
218
218
  at departure), and the log: every reading and event in order, including handwritten notes. A passage in progress
219
219
  refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew logged
220
- themselves can also be **redated** and deleted (with the instrument reading taken when it was logged) — automatic
221
- lines (alarms, autopilot, weather, corrections) can only be annotated. Correcting a time reads the position back from
222
- the track at that time, and leaves it empty when the track has nothing within 2 minutes of it, since the entry was not
223
- made where it happened. A time outside the passage, or in the future, is refused, and the departure manoeuvre that
224
- opened a passage is moved by changing that passage's departure time in **Corrections** below. Under each position, in
225
- grey, its bearing and distance from the nearest landmark.
220
+ themselves can also be **redated** — automatic lines (alarms, autopilot, weather, corrections) can only be annotated.
221
+ Any line can be deleted (with the instrument reading taken when it was logged), and so can a reading on its own, which
222
+ needs an administrator. Correcting a time reads the position back from the track at that time, and leaves it empty
223
+ when the track has nothing within 2 minutes of it, since the entry was not made where it happened. A time outside the
224
+ passage, or in the future, is refused, and the departure manoeuvre that opened a passage is moved by changing that
225
+ passage's departure time in **Corrections** below. Under each position, in grey, its bearing and distance from the
226
+ nearest landmark.
226
227
  - **Corrections** (read/write access):
227
228
  - rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
228
229
  - switch an engine period to sail or back;
@@ -482,6 +483,7 @@ In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
482
483
  | SMTP port | 587 | 587 for submission with STARTTLS, 465 for implicit TLS, 25 for a local relay. |
483
484
  | SMTP security | STARTTLS | STARTTLS, TLS, or none for a relay on the boat itself. |
484
485
  | SMTP user name / password | — | Leave empty if the relay needs no authentication. |
486
+ | Server address for the summary link | — | The address the server is reached at by whoever reads the summary, e.g. `https://boat.example.org`. Each summary then links to its passage in the webapp. Empty leaves the link out. |
485
487
  | Map tiles for the summary | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | The tiles the summary map is drawn over, e.g. a self-hosted server. Empty draws the track on a plain background. |
486
488
  | Logbook language (PDF and summary emails) | en | Language of the PDF logbooks on the USB drive and of the summary emails (English or French). |
487
489
  | Ship's time zone (PDF and summary emails) | the server's | Time zone of the PDF logbooks on the USB drive and of the summary emails, e.g. `Europe/Paris`. |
@@ -595,6 +597,10 @@ summary of every passage once it is over for good.
595
597
  distance, the duration, the time under way, the time under engine and under sail, the average and maximum speed, and
596
598
  the maximum wind — the same figures and wording as the passage page, in the **logbook language** and **ship's time
597
599
  zone** set below.
600
+ - **A link to the passage.** Set **Server address for the summary link** to the address the server is reached at
601
+ (`https://boat.example.org`, or a LAN address if the readers are aboard) and each summary ends with a link that opens
602
+ the passage in the webapp. Without it the summary carries no link, as the plugin cannot know its own address from
603
+ outside.
598
604
  - **The map** is drawn on the server and travels inside the message, so it shows up in the mail rather than as an
599
605
  attachment to open. Tiles come from OpenStreetMap by default; set your own tile server, or clear the setting to get
600
606
  the track drawn on a plain background and send nothing to a tile server.
package/index.js CHANGED
@@ -261,6 +261,12 @@ module.exports = function (app) {
261
261
  title: 'SMTP password',
262
262
  format: 'password'
263
263
  },
264
+ summaryMailServerUrl: {
265
+ type: 'string',
266
+ title: 'Server address for the summary link',
267
+ description:
268
+ 'The address the server is reached at from the recipients’ side, e.g. https://boat.example.org or http://192.168.1.10:3000. Each summary then links to its passage in the logbook; empty leaves the link out'
269
+ },
264
270
  summaryMailTileUrl: {
265
271
  type: 'string',
266
272
  title: 'Map tiles for the summary',
@@ -673,6 +679,7 @@ module.exports = function (app) {
673
679
  : SUMMARY_MAIL_DEFAULTS.smtpSecurity,
674
680
  smtpUsername: config.smtpUsername || null,
675
681
  smtpPassword: config.smtpPassword || null,
682
+ summaryMailServerUrl: config.summaryMailServerUrl || null,
676
683
  summaryMailTileUrl:
677
684
  config.summaryMailTileUrl === undefined
678
685
  ? SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
package/lib/api.js CHANGED
@@ -369,6 +369,13 @@ function registerRoutes(router, { getContext, logError }) {
369
369
  handle(({ db }, req) => track.listObservations(db, entryId(req), v.parsePagination(req.query)))
370
370
  );
371
371
 
372
+ admin.delete(
373
+ '/api/observations/:id',
374
+ handle(({ db }, req) => {
375
+ track.deleteObservation(db, v.parseId(req.params.id));
376
+ })
377
+ );
378
+
372
379
  readonly.get(
373
380
  '/api/entries/:id/propulsion',
374
381
  handle(({ db }, req) => propulsion.listSegments(db, entryId(req), v.parsePagination(req.query)))
@@ -20,6 +20,7 @@ const STYLE = {
20
20
  table: 'width:100%;border-collapse:collapse;margin-top:16px;font-size:14px;',
21
21
  label: 'padding:6px 12px 6px 0;color:#52606d;white-space:nowrap;',
22
22
  value: 'padding:6px 0;text-align:right;font-weight:bold;color:#102a43;',
23
+ link: 'margin:16px 0 0;font-size:14px;',
23
24
  footer: 'margin:18px 0 0;font-size:12px;color:#7b8794;'
24
25
  };
25
26
 
@@ -103,10 +104,11 @@ function htmlRow(label, value) {
103
104
  }
104
105
 
105
106
  // `mapAvailable` says whether an image part accompanies the message; without
106
- // one the <img> would show as a broken attachment.
107
+ // one the <img> would show as a broken attachment. `passageUrl`, when given, is
108
+ // where the passage can be opened in the webapp.
107
109
  async function renderPassageSummary(
108
110
  { entry, trackPoints = [] },
109
- { language = 'en', timeZone, vesselName = null, mapAvailable = false } = {}
111
+ { language = 'en', timeZone, vesselName = null, mapAvailable = false, passageUrl = null } = {}
110
112
  ) {
111
113
  const { i18n, format: formatModule } = await loadShared();
112
114
  const t = i18n.createTranslator(language);
@@ -139,6 +141,7 @@ async function renderPassageSummary(
139
141
  '',
140
142
  ...facts.map(([label, value]) => `${label}: ${value}`),
141
143
  ...(trackPoints.length === 0 ? ['', t('passage.noTrack')] : []),
144
+ ...(passageUrl ? ['', `${t('mail.link')}: ${passageUrl}`] : []),
142
145
  '',
143
146
  t('mail.footer')
144
147
  ].join('\n');
@@ -158,6 +161,11 @@ ${
158
161
  ${lines.map(({ label, place, when: at }) => htmlRow(label, at ? `${place} — ${at}` : place)).join('\n')}
159
162
  ${facts.map(([label, value]) => htmlRow(label, value)).join('\n')}
160
163
  </table>
164
+ ${
165
+ passageUrl
166
+ ? `<p style="${STYLE.link}"><a href="${escapeHtml(passageUrl)}">${escapeHtml(t('mail.link'))}</a></p>`
167
+ : ''
168
+ }
161
169
  <p style="${STYLE.footer}">${escapeHtml(t('mail.footer'))}</p>
162
170
  </div>
163
171
  </body></html>
@@ -79,6 +79,16 @@ function writeOffStale(db, now) {
79
79
  });
80
80
  }
81
81
 
82
+ // Where a passage can be opened in the webapp, from the address the crew reaches
83
+ // the server at; nothing when none is configured -- the plugin cannot know its
84
+ // own address from the outside.
85
+ function passageUrl(baseUrl, entryId) {
86
+ const base = String(baseUrl ?? '')
87
+ .trim()
88
+ .replace(/\/+$/, '');
89
+ return /^https?:\/\/\S+$/i.test(base) ? `${base}/signalk-chiplog/#/passages/${entryId}` : null;
90
+ }
91
+
82
92
  function configurationProblem(settings) {
83
93
  if (!settings.smtpHost) {
84
94
  return 'no SMTP server is configured';
@@ -135,7 +145,8 @@ function createSummaryMailer({
135
145
  language: settings.logbookLanguage,
136
146
  timeZone: settings.logbookTimeZone ?? undefined,
137
147
  vesselName: vessel,
138
- mapAvailable: map !== null
148
+ mapAvailable: map !== null,
149
+ passageUrl: passageUrl(settings.summaryMailServerUrl, entryId)
139
150
  }
140
151
  );
141
152
 
package/lib/track.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { requireEntryRow } = require('./entries');
2
+ const { notFound } = require('./errors');
2
3
  const { toPosition, paginate } = require('./rows');
3
4
 
4
5
  function toTrackPoint(row) {
@@ -68,4 +69,11 @@ function listObservations(db, entryId, { limit, offset }) {
68
69
  });
69
70
  }
70
71
 
71
- module.exports = { allTrackPoints, allObservations, listObservations };
72
+ function deleteObservation(db, id) {
73
+ const { changes } = db.prepare('DELETE FROM observations WHERE id = ?').run(id);
74
+ if (changes === 0) {
75
+ throw notFound('observation', id);
76
+ }
77
+ }
78
+
79
+ module.exports = { allTrackPoints, allObservations, listObservations, deleteObservation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
package/public/app.css CHANGED
@@ -78,6 +78,16 @@ button.link-button {
78
78
  padding: 0.2rem 0.4rem;
79
79
  }
80
80
 
81
+ button.icon-button {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ padding: 0.25rem;
85
+ }
86
+
87
+ button.icon-button.danger {
88
+ border: none;
89
+ }
90
+
81
91
  button.danger {
82
92
  background: none;
83
93
  color: var(--danger);
@@ -735,6 +745,17 @@ main {
735
745
  stroke-width: 1;
736
746
  }
737
747
 
748
+ .tide-tick {
749
+ stroke: var(--border);
750
+ stroke-width: 1;
751
+ }
752
+
753
+ .tide-hour {
754
+ fill: var(--muted);
755
+ font-size: 11px;
756
+ text-anchor: middle;
757
+ }
758
+
738
759
  .tide-area {
739
760
  fill: var(--accent);
740
761
  opacity: 0.12;
@@ -373,6 +373,12 @@ export function PassageView({ id }) {
373
373
  await request('DELETE', `/events/${event.id}`);
374
374
  reload();
375
375
  });
376
+ const deleteReading = (reading) =>
377
+ confirm(t('timeline.deleteReadingConfirm')) &&
378
+ act(async () => {
379
+ await request('DELETE', `/observations/${reading.id}`);
380
+ reload();
381
+ });
376
382
 
377
383
  const sameDay =
378
384
  entry.endTime && dayKey(new Date(entry.startTime)) === dayKey(new Date(entry.endTime));
@@ -539,6 +545,7 @@ export function PassageView({ id }) {
539
545
  busy=${busy}
540
546
  onEditEvent=${editEvent}
541
547
  onDelete=${deleteEvent}
548
+ onDeleteReading=${deleteReading}
542
549
  />
543
550
  </section>
544
551
 
@@ -1,10 +1,12 @@
1
1
  import { html } from '../../vendor/preact-htm.mjs';
2
2
  import { useLocale } from '../context.mjs';
3
- import { smoothPath, tideExtremes } from '../tide.mjs';
3
+ import { hourTicks, smoothPath, tideExtremes } from '../tide.mjs';
4
4
 
5
5
  const CHART_WIDTH = 600;
6
- const CHART_HEIGHT = 160;
7
- const PADDING = { top: 16, right: 12, bottom: 12, left: 12 };
6
+ const CHART_HEIGHT = 180;
7
+ // The bottom margin holds the hour scale.
8
+ const PADDING = { top: 16, right: 12, bottom: 32, left: 12 };
9
+ const TICK_LENGTH = 4;
8
10
 
9
11
  // Pixel coordinates for the curve, an area fill down to the baseline, and the
10
12
  // scales used to place the high/low markers at the same spots.
@@ -37,6 +39,7 @@ export function TideCard({ tide, placeName }) {
37
39
  }
38
40
  const chart = layout(points);
39
41
  const extremes = tideExtremes(points);
42
+ const ticks = hourTicks(points, format.time);
40
43
 
41
44
  return html`
42
45
  <section class="card tide-card">
@@ -57,6 +60,21 @@ export function TideCard({ tide, placeName }) {
57
60
  y2=${chart.baseline}
58
61
  class="tide-baseline"
59
62
  />
63
+ ${ticks.map(
64
+ (tick) =>
65
+ html`<g key=${tick.time}>
66
+ <line
67
+ x1=${chart.x(tick.time)}
68
+ y1=${chart.baseline}
69
+ x2=${chart.x(tick.time)}
70
+ y2=${chart.baseline + TICK_LENGTH}
71
+ class="tide-tick"
72
+ />
73
+ <text x=${chart.x(tick.time)} y=${chart.baseline + TICK_LENGTH + 13} class="tide-hour">
74
+ ${tick.label}
75
+ </text>
76
+ </g>`
77
+ )}
60
78
  <path d=${chart.areaPath} class="tide-area" />
61
79
  <path d=${chart.linePath} class="tide-line" />
62
80
  ${extremes.map(
@@ -2,6 +2,7 @@ import { html, useEffect, useRef, useState } from '../../vendor/preact-htm.mjs';
2
2
  import { useLocale } from '../context.mjs';
3
3
  import { dayKey } from '../days.mjs';
4
4
  import { landmarkLine } from '../landmarks.mjs';
5
+ import { IconButton } from './common.mjs';
5
6
  import { EDITABLE_EVENT_TYPES, buildRows, describeEvent } from '../log-lines.mjs';
6
7
 
7
8
  // A dot — the stroke of an i or a full stop — has a single point, which a
@@ -68,10 +69,9 @@ export function EventRemark({ event, manoeuvreLabels }) {
68
69
  // the comment on anything the plugin logged itself (SPEC: "an edited comment");
69
70
  // what the crew logged (`EDITABLE_EVENT_TYPES`) can also be redated, which makes
70
71
  // the server re-read its position from the track. Deleting is offered for what
71
- // the crew themselves logged (`source: "manual"`) and for an alarm (`sk_alarm`)
72
- // — which takes its paired line, the raise or the clearing, with it
73
- // (lib/events.js `pairedAlarmEvent`); other automatic lines (autopilot, weather,
74
- // corrections) can only be annotated.
72
+ // every line — an alarm (`sk_alarm`) takes its paired line, the raise or the
73
+ // clearing, with it (lib/events.js `pairedAlarmEvent`). The server decides who
74
+ // may: an administrator for a reading, whoever has write access for a line.
75
75
  function EventLine({ event, bounds, manoeuvreLabels, busy, onEditEvent, onDelete }) {
76
76
  const { t, format } = useLocale();
77
77
  const [editing, setEditing] = useState(false);
@@ -143,25 +143,19 @@ function EventLine({ event, bounds, manoeuvreLabels, busy, onEditEvent, onDelete
143
143
  return html`
144
144
  <${EventRemark} event=${event} manoeuvreLabels=${manoeuvreLabels} />
145
145
  <div class="timeline-actions">
146
- <button
147
- type="button"
148
- class="link-button"
146
+ <${IconButton}
147
+ icon="edit"
148
+ label=${t('common.edit')}
149
149
  disabled=${busy}
150
150
  onClick=${() => setEditing(true)}
151
- >
152
- ${t('common.edit')}
153
- </button>
154
- ${
155
- (event.source === 'manual' || event.type === 'sk_alarm') &&
156
- html`<button
157
- type="button"
158
- class="link-button danger"
159
- disabled=${busy}
160
- onClick=${() => onDelete(event)}
161
- >
162
- ${t('common.delete')}
163
- </button>`
164
- }
151
+ />
152
+ <${IconButton}
153
+ icon="delete"
154
+ label=${t('common.delete')}
155
+ danger
156
+ disabled=${busy}
157
+ onClick=${() => onDelete(event)}
158
+ />
165
159
  </div>
166
160
  `;
167
161
  }
@@ -174,7 +168,8 @@ export function Timeline({
174
168
  manoeuvreLabels,
175
169
  busy,
176
170
  onEditEvent,
177
- onDelete
171
+ onDelete,
172
+ onDeleteReading
178
173
  }) {
179
174
  const { t, format } = useLocale();
180
175
  const rows = buildRows(events, observations);
@@ -260,7 +255,16 @@ export function Timeline({
260
255
  onEditEvent=${onEditEvent}
261
256
  onDelete=${onDelete}
262
257
  />`
263
- : html`<span class="muted">${t(`observation.${readings.reason}`)}</span>`
258
+ : html`<span class="muted">${t(`observation.${readings.reason}`)}</span>
259
+ <div class="timeline-actions">
260
+ <${IconButton}
261
+ icon="delete"
262
+ label=${t('common.delete')}
263
+ danger
264
+ disabled=${busy}
265
+ onClick=${() => onDeleteReading(readings)}
266
+ />
267
+ </div>`
264
268
  }
265
269
  </td>
266
270
  </tr>
@@ -9,6 +9,37 @@ export function SignInLink({ label }) {
9
9
  return html`<a href="/admin/#/login" target="_blank" rel="noopener">${label}</a>`;
10
10
  }
11
11
 
12
+ const ICON_PATHS = {
13
+ edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z',
14
+ delete: 'M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 10v6M14 10v6'
15
+ };
16
+
17
+ // A button drawn as an icon, named for screen readers and as a tooltip.
18
+ export function IconButton({ icon, label, danger, disabled, onClick }) {
19
+ return html`<button
20
+ type="button"
21
+ class=${`link-button icon-button${danger ? ' danger' : ''}`}
22
+ disabled=${disabled}
23
+ title=${label}
24
+ aria-label=${label}
25
+ onClick=${onClick}
26
+ >
27
+ <svg
28
+ viewBox="0 0 24 24"
29
+ width="16"
30
+ height="16"
31
+ fill="none"
32
+ stroke="currentColor"
33
+ stroke-width="2"
34
+ stroke-linecap="round"
35
+ stroke-linejoin="round"
36
+ aria-hidden="true"
37
+ >
38
+ <path d=${ICON_PATHS[icon]} />
39
+ </svg>
40
+ </button>`;
41
+ }
42
+
12
43
  // Failed actions waiting for the link to come back, so the toast can say, once
13
44
  // it does, that there is something to do again.
14
45
  const unfinished = new Set();
@@ -219,6 +219,7 @@ export const MESSAGES = {
219
219
  'mail.subjectVessel': '{vessel} — {route}',
220
220
  'mail.when': '{date} ({zone})',
221
221
  'mail.mapAlt': 'The track of the passage on the map',
222
+ 'mail.link': 'Open the passage in the logbook',
222
223
  'mail.footer': 'Sent automatically by Chiplog, this vessel’s logbook.',
223
224
 
224
225
  'map.seamarks': 'Seamarks',
@@ -305,6 +306,7 @@ export const MESSAGES = {
305
306
  'timeline.timeFromTrack': 'Changing the time reads the position back from the track.',
306
307
  'timeline.deleteConfirm': 'Delete this log line? This cannot be undone.',
307
308
  'timeline.deleteAlarmConfirm': 'Delete this alarm and its resolution? This cannot be undone.',
309
+ 'timeline.deleteReadingConfirm': 'Delete this instrument reading? This cannot be undone.',
308
310
 
309
311
  // Where the position is from the landmark, the way a logbook gives it.
310
312
  'landmark.bearing': '{distance} {cardinal} ({bearing}) — {name}',
@@ -801,6 +803,7 @@ export const MESSAGES = {
801
803
  'mail.subjectVessel': '{vessel} — {route}',
802
804
  'mail.when': 'Le {date} ({zone})',
803
805
  'mail.mapAlt': 'La trace de la navigation sur la carte',
806
+ 'mail.link': 'Ouvrir la navigation dans le journal',
804
807
  'mail.footer': 'Envoyé automatiquement par Chiplog, le journal de bord de ce navire.',
805
808
 
806
809
  'map.seamarks': 'Balisage',
@@ -888,6 +891,7 @@ export const MESSAGES = {
888
891
  'timeline.timeFromTrack': 'Changer l’heure relit la position dans la trace.',
889
892
  'timeline.deleteConfirm': 'Supprimer cette ligne du journal ? C’est irréversible.',
890
893
  'timeline.deleteAlarmConfirm': 'Supprimer cette alarme et sa résolution ? C’est irréversible.',
894
+ 'timeline.deleteReadingConfirm': 'Supprimer ce relevé des instruments ? C’est irréversible.',
891
895
 
892
896
  // Le relèvement inverse : où se trouve le bateau par rapport à l’amer.
893
897
  // Sans préposition, qui varierait avec l’aire de vent (au NE, à l’E).
@@ -17,6 +17,17 @@ export function tideExtremes(points) {
17
17
  return extremes;
18
18
  }
19
19
 
20
+ // The hour scale under the curve: the samples whose local hour is a multiple of
21
+ // `step`, labelled by `label` (the formatter's `time`, so the zone and the
22
+ // 24-hour clock are the reader's). Samples are used as they are rather than
23
+ // recomputed from the clock, so a zone that is not on a whole hour still lines
24
+ // up with the curve.
25
+ export function hourTicks(points, label, step = 3) {
26
+ return points
27
+ .map((point) => ({ time: point.time, label: label(point.time) }))
28
+ .filter((tick) => Number(tick.label.slice(0, 2)) % step === 0);
29
+ }
30
+
20
31
  // A smoothed SVG path through the points, quadratic Bezier segments via
21
32
  // midpoints -- simple, and hourly samples are dense enough that it reads as
22
33
  // a real tide curve rather than a jagged line.