Skip to content

Track editor

Rails
Status ✅
Code app/controllers/tracks_controller.rb, app/views/layouts/editor.html.erb, app/views/tracks/edit.html.erb, app/javascript/controllers/track_editor_controller.js, app/javascript/track_view/ (histogram, instruments, boat, map wind, geometry and the track file decoder, shared with the trip replay), app/javascript/maps/wind_barb.js, app/services/engine_detector.rb, app/services/track_state_changes.rb, app/services/open_meteo_wind_service.rb, app/models/track.rb, config/locales/logbook.en.yml (logbook.editor.*)

Verified against code: 2026-10-27

Purpose

Fix what the watch got wrong on one track, on one fullscreen screen: cut off the walk to the marina or the drive home (trim IN / OUT), name the departure and destination, and set when the boat sailed and when it motored, with a note on each change. Nothing is deleted: the recorded points stay, so every trim can be undone. Owner and skipper can open it (/my/tracks/:id/edit, permission edit_tracks, sharing.md § Roles). Moving a track to another trip is on the trip page (trip.md § Tracks table).

How a saved trim changes distance and times (device distance scaled by the kept share, recompute job) is in ingestion.md § Track metrics.

Layout / behaviour

Fullscreen (layout editor: no site header or footer). The page always fills the window without scrolling, top to bottom: action bar, notice, map with the plotter column on its right, the tool row (zoom · selection panel · Analyze track), timeline. The map takes the height that is left. Below 900 px width the plotter column is 65 px wide with smaller values: no Date box, no wind barb, no units on Wind and Kept, and in Wind and Kept each label above its value (it scrolls if the boxes don't fit). The selection panel moves to its own line below zoom and Analyze, and the histogram is 104 px high.

While the points load, "Loading the track…" over the map; if loading fails, "The track couldn't be loaded. Reload the page to try again." With fewer than 2 points nothing can be edited.

Action bar

Element Content
Close (×) icon button, "Close without saving" → the trip's Tracks tab (no trip: /my/trips)
Title "EDIT TRACK", plus " · → " when the track has names; one line, cut with "…"
‹ › Previous / next track of the same trip by start time ("Previous track" / "Next track") → its editor; greyed out where there is none (and both without a trip)
Save track Primary button; submits everything (below)

Close, ‹ and › ask first when something was edited and not saved: "Leave the track editor? Your changes aren't saved." (cancel stays).

Notice (below the bar, after Analyze track, see Tools): "Suggestion from the speed: an even speed is engine, a step in speed is the engine going on or off. About 96% of the minutes are likely right. Check it, then save." (the share is the detector's reliability); "Too little movement to suggest anything."; or "The track couldn't be analyzed. Try again." Errors from a failed save show there as a list.

Map

SailEver's map tiles with the depth layer and the seamarks (maps.md), fitted to the whole recording. The whole recording as a grey dashed line (#8A9AA5, weight 3, dash 4/6); the kept part (IN … OUT) on top in the state colours (--sail, --engine, --motorsail, weight 4). IN: white circle with a dark border; OUT: white square. Each change after the first: a dot in its state's colour with a white ring (the selected one larger, yellow ring #FFD84D). The boat at the playhead: a 24 px arrow turned to its course, in its state's colour with a 2 px white outline (track_view/boat_marker.js). The scale bottom left. Wind barbs (40 px, ink with a white halo) for the playhead's moment: one in every fetched wind cell, sea or land, and one halfway between neighbouring cells (twice the density), its value interpolated from the cells around it. Only where all surrounding cells were fetched; none is made up at the edge of the band. Zoomed out so far that arrows half a cell apart would be closer than 40 px, only the fetched cells are drawn. The arrows follow the playhead in 10-minute steps. A click on the map moves the playhead to the nearest point.

Plotter boxes

Right of the map, 232 px wide, like a chart plotter's data bar: always white on night navy (#071422 boxes on #02070D), whatever the theme. Each box: label top left and unit top right (mono, 11 px, #9FB3C8), the value right-aligned (display font, 40 px). The values are those at the playhead ("where the track head points"):

Box Value
Date Mon, Sep 25, 2023, the trip's time zone (display font 24 px)
Time · LOC 13:25:10, 24 h, the trip's time zone
SOG · kn Speed over ground of the nearest point, 5.3
Log · NM Distance from IN to the playhead, 3.3 (0.0 before IN); great-circle between the points, steps over 5 minutes skipped
COG · ° Course from the positions 15 s before to 15 s after, 090; "–" when the boat moved less than 5 m (the stored COG of old imports can be wrong)
Wind · model Barb (44 px, white, as large as in the timeline); TWD 020° (direction the wind comes from); TWA P 70° or S 70° (angle between course and wind, P = from port, S = from starboard); TWS 14.0 kn. The stored model wind of the nearest cell and hour (wind endpoint below); "–" without.
Baro · hPa Pressure of the nearest point, 1013; the box only shows when the track has pressure at all
Kept · NM · h Total distance of the kept part and its duration (11.8 2:33), S sailed distance (NM), E engine time (0:48). Computed in the browser from the points: great-circle distance, steps over 5 minutes skipped, a step counts by the state at its first point (motorsail as engine). The saved numbers come from the server and can differ.

Timeline

Canvas, full width, 144 px; 14 px are kept free at both ends so handles at the edges can be grabbed. From the top:

Row Content
Handles (22 px) One tab per change after IN (16 × 20 px, rounded, in the state's colour, three white grip lines; a white dot when it has a note). The state at IN is the first change and has no tab of its own: IN's handle is it. Changes at or before IN aren't shown. A new change waiting for its state shows as a dashed yellow tab at the playhead.
State band (9 px) The state at each moment
Suggestion (4 px) After analyzing: the detector's P(engine) per minute, engine or sail colour, stronger the surer
Speed Speed over ground, one column per pixel (highest SOG in it), state colour; scale 8–25 kn; grid lines at 5/10/15/20 kn
Axis (16 px) Ticks from 1 min to 24 h so that labels are at least 70 px apart, on whole local hours; midnight shows the day (Tue, Sep 26)

Over these: each change after the first as a 2 px line in its colour from its tab down through the speed; outside IN … OUT a dark hatched shade (the cut-off parts); IN and OUT as tall white brackets (12 px wide, three grip lines, a lip towards the kept part, the label "IN" / "OUT" beside them); the playhead as an ink line with a dot on the axis. The selected handle is yellow (#FFD84D). OUT is grey while the watch is still sending the track.

Input Result
Pointer over IN, OUT (12 px) or a change (9 px) Cursor ew-resize; over the locked OUT not-allowed; elsewhere crosshair
Drag IN / OUT Trims; snaps to points; IN stays at least a minute before OUT. The playhead follows the handle. Released, the panel shows the place there (below).
Drag a change Moves it, at least a minute after the change before and before the next; cursor grabbing while dragging
Click a handle Selects it (IN, OUT or a change); the playhead jumps to it
Click or drag elsewhere Moves the playhead and opens the change form there (below): the change within 30 s of it, or a new one waiting for its state
Wheel / trackpad pinch Zooms around the pointer (to 10 minutes at most); horizontal scrolling pans
← → (timeline focused; Shift: 10 min) Moves the selected handle a minute, otherwise the playhead (with its change form)
Delete / Backspace, Escape Deletes the selected change (not the first); deselects
Home / End, + / − Playhead to the start / end; zoom in / out

Overview (20 px below): the whole recording's states (cut-off parts faded), the visible window as a frame and the playhead; clicking or dragging centres the window there.

Wind (52 px, at the very bottom, under the overview): a barb per hour (44 px, as large as in the wind box; fewer when hours are closer than 50 px) on the histogram's time scale, wind cell nearest the boat at that hour. Not clickable.

The timeline is a focusable slider (role="slider", aria-valuetext "13:25 Sail"); its label explains the keys.

Selection panel

Centred in the tool row, between the delete / undo / redo group and Analyze track. Without a selection: a (?) "How the timeline works" with "Click the timeline to set a state there · drag IN / OUT to trim · drag a change to move it · click a handle to name it or add a note".

Selected Panel
IN Tag "IN · 11:12" (white), the departure name field (placeholder "Departure"), "↺ Auto", and the state at IN as three toggle buttons Sail / Engine / Motorsail: IN is the first change. Setting it folds every change at or before IN into it.
OUT Tag "OUT · 17:51" (with a lock while receiving), the destination name field ("Destination"), "↺ Auto"
The playhead (no change there) Tag "CHANGE · 12:34" and the three state buttons, none pressed. A click on one adds the change at the playhead (at IN or before it: sets IN's state) and shows its note field, filled at once with the place name there.
A change Tag "CHANGE · 12:34", the state buttons (the current one filled in its colour), the note field (placeholder "Note, e.g. Sails up off Kea", at most 200 characters). Deleting is the trash button in the tool row.

Equal neighbouring changes merge.

Names. A departure or destination name that wasn't typed (and isn't stored as typed) follows IN / OUT: when the handle is released the field shows the place name there (GET /my/tracks/:id/place.json), and it isn't sent on save, so the recompute names the track from its kept first / last point (ingestion.md § Names). A typed name is sent and stored as typed; later recomputes keep it. "Auto" fills the field with the place name at the handle and makes the name automatic again (reset_*_name).

Notes. A change added from the playhead or suggested by Analyze gets the place name at its position as its note right away (if it has none); the skipper can change or clear it. Notes are saved with the changes and show on the trip map's state-change markers (trip.md § Map).

Tools

The row above the speed chart. Left: zoom out, zoom in (magnifier − / + icons) and "Whole track" (frame icon), 44 px icon buttons; then a button group of three icon buttons: Delete change (trash, only enabled while a change after IN is selected; also Delete / Backspace on the timeline), Undo and Redo (Undo and redo). Centre: the selection panel. Right: Analyze track (compass icon, 44 px high) asks POST /my/tracks/:id/analyze.json and replaces the changes with the suggestion; with more than one change it asks first ("Replace the current sail and engine changes with the suggestion? Nothing is saved until you save the track."). While it runs: "Analyzing…". The panel stays open: IN or OUT stay selected, a change form moves to the suggested change at the playhead or waits for a state there. Each suggested change after IN gets its note from our places.

Deleting a track is "No trip (DELETE)" in its Move dialog on the trip page (trip.md § Tracks table), not in the editor.

Undo and redo

Everything edited before saving can be undone: trims, adding, moving and deleting changes, their states and notes, Analyze, and the departure / destination names (typed or "Auto"). The editor keeps a snapshot of what Save would send after each settled edit (a drag on release; typing coalesced over 400 ms). Undo goes back one snapshot, Redo forward; a new edit after an undo drops the redo steps. Keys: Ctrl/⌘+Z undo, Ctrl/⌘+Shift+Z and Ctrl+Y redo, except while the focus is in a text field (the browser undoes the text there). The buttons are greyed out with nothing to undo or redo. Undoing back to the start counts as unchanged for the "Leave the track editor?" question. The history starts again with every page load and after Save.

What is saved

"Save track" sends:

Field Content
trim_start_at, trim_end_at IN / OUT as the handle's point time; empty when the handle is at the first / last point. Trims are times, not point ids, so re-decoded segments keep them. OUT isn't changed while the watch is still sending.
start_name, end_name, reset_*_name Only when typed or set to Auto (above)
state_changes Only when a change was edited: JSON [{"at": ISO, "state": "sail"\|"engine"\|"motorsail", "note": "…"}]

TrackStateChanges.apply: every underway point gets the state of the last change at or before it (the first change from the recording's start), with engine_on true for engine and motorsail; stationary points (anchor, mooring, buoy, shoreline, adrift) are not touched. The notes go to tracks.state_notes. More than 500 changes, an unknown state or an unreadable list are ignored. On load the changes are read back from the points (TrackStateChanges.current: stationary points skipped, a sail point with the engine on counts as engine), each with the note saved within 60 s of it.

Case Result
Trim or changes saved The track is queued for a recompute (recompute_requested_at, TrackRecomputeJob): distance, sailing and engine time, events, names, trip and days. The editor opens again on the same track (so ‹ › go on from there) with the notice "Track saved. The numbers are being recalculated…"; the trip page shows "Recalculating…" until the job is done. The editor page is replaced, not morphed (turbo-refresh-method replace on this page only), so it loads its points again; a morph kept the old editor connected over an emptied map ("Loading the track…" forever).
Only names changed Trip recomputed at once; the editor again with "Track saved."
IN is not before OUT Editor again (422) with the error

Points endpoint

GET /my/tracks/:id/points returns the track's file as stored (gzip, application/vnd.sailever.track, data-model.md § Track file): every point of the whole recording on the 5 s grid, ignoring trims. ETag on the file (private): an unchanged file is answered with 304.

The browser decompresses it (DecompressionStream) and decodes it into [epoch ms, lat, lon, sog kn, engine 0/1, vessel state, cog °, baro hPa] per point (track_view/track_file.js):

  • Latitude and longitude to 1e-7°, SOG to 0.01 kn (0 without), COG to whole degrees, pressure to 0.1 hPa (null without).
  • Engine is 1 when the point has the engine on or its vessel state is engine or motorsail.
  • The editor draws at most 50 000 points (one map's budget, the same as the trip replay): longer tracks are thinned evenly in the browser to every n-th point, and the last point is always included. A day's track (a point every 5 s) is always drawn in full; a week-long regatta track of 81 000 points is drawn with every second one.

Analyze endpoint

POST /my/tracks/:id/analyze.json runs the engine detection on all points (ignoring trims) and saves nothing:

{"changes": [{"at": ISO, "state": "engine"|"sail"}, …], "minutes": [[epoch ms, P(engine) or null], …], "reliability": 0.83}

minutes has one entry per minute; null where the boat wasn't moving. Without enough points: no changes, reliability null.

Place endpoint

GET /my/tracks/:id/place.json?at=ISO: {"name": …}, the place name (ingestion.md § Names, our own OSM places, nothing sent outside) of the last point at or before that time (the first point when the time is before the recording); null without a name.

Wind endpoint

GET /my/tracks/:id/wind.json: the model wind stored with the track (tracks.wind, filled by TrackWindJob, ingestion.md § Wind); fetched and stored right away when it isn't there or doesn't cover the track yet. OpenMeteoWindService builds it: hourly model wind in the 0.25° cells the track passes through (one point per 10 minutes) and the ring of 8 cells around each, a band about 28 km wide on each side of the track rather than a rectangle, so a long trip doesn't load a whole sea. At most 120 cells: above that the passed cells are thinned evenly, each keeping its whole ring. For every UTC day of the track:

{"band": 2, "step": 0.25, "times": [epoch ms, hourly], "cells": [{"lat", "lon", "speed": [kn], "direction": [° from]}]}

  • Wind stored before the band (no "band": 2) counts as not covering the track and is fetched again (ingestion.md § Wind).
  • Between cells and hours the browser interpolates (track_view/wind_field.js): bilinear between the four cells around a place, linear between the two hours around a moment; speed as a number, the direction through its vector (350° and 10° give 0°). The wind box, the wind row under the timeline and the extra map arrows use it; where a surrounding cell is missing, the wind box and the row take the nearest cell. Nothing interpolated is stored.

  • Tracks from 2022 on: Open-Meteo's historical forecast API (finer models, also the last days); older ones the ERA5 archive. Speeds rounded to 0.1 kn, directions to whole degrees; null where the model has no value.

  • Open-Meteo gets only the cells and dates, no user or track ID.
  • If Open-Meteo fails: HTTP 502 with no cells; the wind boxes and barbs stay empty ("–").
  • The wind is for the skipper to judge; the detector doesn't use it (model wind near islands was too far off, see ADR 011).

States and edge cases

  • A track without a trip uses UTC for all clock times.
  • Touch pinch zoom on the timeline isn't supported (the − / + buttons are).
  • A track still receiving can be trimmed at the start only. Its OUT value is never changed while receiving, even if one is sent.
  • Very long tracks (over 50 000 points): IN / OUT snap to the thinned points.

Known deviations

  • The other languages' locale files still hold the old editor keys (trim card, trip card); the signed-in UI is English only, so they are unused.
  • The copy talks about "the watch" for a paired device ("The watch is still sending this track").