Distance - Port-to-Port Voyage, Distance & Round-Trip Economics Analyzer
The Distance page (route /routes) answers one question in depth: for a single origin→destination port pair, how far is it, how long does the voyage actually take, and what does the round trip cost? It fuses a computed reference sea distance (routed through real ocean lanes and canals, not a straight line over land) with the real observed transit times of tracked vessels that have sailed that exact pair - broken down by ship size, ISO week, month of year, and individual voyage - and adds a separate round-trip economics block that turns the head-haul + back-haul into days, fuel, CO₂ and a break-even freight rate.
What this page is for and how the data is built
Despite the menu label Distance, this is really a port-pair voyage analyzer. It fuses two independent data sources:
- A computed reference distance - the nautical-mile figure for the chosen pair, calculated server-side by
route_analysis(). It calls thesearoutelibrary, which routes through a precomputed marine network (MARNET) graph plus the major canals, so Rotterdam→Singapore correctly returns roughly 8,400 nm via Suez rather than the ~4,800 nm a straight line would cut through the Euro-Asian landmass. There are two safety nets in the code: if searoute fails, the page falls back to a great-circle (haversine) distance; and if searoute returns a distance shorter than the great circle (its graph collapsed two close endpoints to one node), the page overrides it with the straight line, because a sea route can never be shorter than a great circle. When that override fires, the duration estimate and the curved map line are dropped too. - Observed transit times - the actual days-underway of vessels the platform has tracked sailing this exact pair, read from derived voyage legs (the
voyage_legtable). A route only appears once real vessels have sailed it.
A subtlety to understand before trusting the numbers: legs are collapsed into "macro" cargo voyages by _macro_legs_for_ship(). A port stop counts as a real cargo event only when the ship's load state changes across it (Ballast→Laden or vice-versa, inferred from draught). If a ship stops mid-trip for bunkers, a canal-transit wait, or a refresher call without changing load state, that stop is merged into the longer journey and its sea-days are summed in. So Tubarão→Qingdao stays one voyage even when the ship paused in Singapore for bunkers. There is also a raw-leg fallback: if the macro pass finds nothing for the exact pair you picked (because that port was a mid-chain call that got merged away), the page re-runs against the raw physical legs so every route offered in the dropdown still returns its voyages.
Two more behaviours worth knowing. Anchorages are folded into their berth port: a voyage that ended "at the Antwerp anchorage" still counts toward the Port-A→Antwerp pair and uses the full berth-to-berth distance. And pure bunker/transit hubs are excluded from the route chips and observed-pair counts entirely (you can still type-and-pick them above for a point-to-point distance) (_NONLOAD_PORTS - Singapore, Zhoushan, Fujairah, Gibraltar, Suez/Panama anchorages, etc.), because they are waypoints, not cargo endpoints.
The grey explainer note (read this first)
Just above the headline summary sits a muted explainer line. It is worth reading once because it sets expectations for the whole page:
"Transit = days underway between the two ports (derived voyage legs). Distance is computed through real ocean lanes (Suez / Panama / etc.) when available, otherwise great-circle. Data grows with coverage & time; free DMA backfill is Baltic/North-Sea only, so Mediterranean routes fill in slowly from live tracking."
- "Transit = days underway" - confirms the transit days are sea time (
sea_days), not port time. Waiting and berth days live in the round-trip block, not in the headline transit figure. - The backfill caveat is a coverage warning, not an error. Historical backfill is Baltic/North-Sea only; everywhere else - especially the Mediterranean - fills in slowly from live AIS tracking. So thin samples on a Med lane mean "not enough history yet," not "this route is wrong." Always check the voyage count (KPI card Voyages / the
nannotations on the charts) before trusting an average.
The port pickers (From / To) and the Swap button
The filter bar at the top opens with the two core controls:
- From (origin,
#o) and To (destination,#d) are type-to-search port pickers. Start typing a port name or LOCODE (2+ characters) and a live dropdown of matches appears - each row shows the port name, its LOCODE and country. Click a row (or use the ↑/↓ arrow keys and Enter) to pick it. The lookup is served by/api/searchover the full port gazetteer, so you can choose any port - not only ones that already have voyage legs. (This replaced the old fixed drop-down lists, which grew unwieldy to scroll as voyage coverage matured.) - Distance resolves for any pair. The sea-route distance and transit are computed geometrically through the real ocean-lane graph (Suez / Panama, etc.), so a valid From→To always returns a route and a map line - even if no ship has yet been observed on that exact lane. The observed-voyage evidence (the KPI cards, the Voyages table, round-trip economics) simply fills in when matching legs exist and stays empty otherwise.
- Swap button (the
⇆arrows, tooltip "Swap direction"). One click reverses origin and destination and re-runs. This matters because head-haul and back-haul are not symmetric: a laden Tubarão→Qingdao leg and a ballast Qingdao→Tubarão leg are different voyages with different speeds, durations and (often) slightly different distances if the searoute graph routes them differently.
How to use it: type the load port in From and the discharge port in To (or click a route chip below to pre-fill a busy observed lane), read the distance and transit, then use Swap to compare the laden head-haul against the ballast return.
Quick-pick route chips, the empty-state, and URL deep-linking
Under the filter bar sits the chips strip (#chips): up to 24 pre-built buttons of the form Origin → Destination (N), where N is the voyage count for that pair. These are the busiest observed lanes fleet-wide (served by /api/route_ports, which returns the top 80 pairs; the page shows the first 24). Clicking a chip sets both pickers and runs the analysis in one move - the fastest way to land on a high-sample route. Chips have a pill style and highlight on hover.
- Empty state: if there are no ports at all, the chips strip shows "No derived voyage legs yet - routes appear here as ships complete tracked port-to-port trips." That is expected on sparse coverage, not a bug.
- Deep-linking: the page reads
?o=and?d=from the URL on load, and rewrites the address bar to/routes?o=…&d=…after each run (history.replaceState). You can bookmark or share a specific lane and it reopens on exactly that pair. If the?o=in the URL is not a valid origin, the page falls back to the busiest pair.
Note on dates: the page deliberately clears the From/To date fields on load. They share the element IDs #since/#until with the Port page, so a browser could auto-fill a stale value; the Distance page defaults to all dates on purpose, because a stray "today" value would silently filter every voyage out and make a populated route look empty.
Filters: Load state, DWT range, date window, and Segment
The remaining filter-bar controls narrow which voyages feed every table, chart and KPI below. Click Apply to commit them (changing the origin re-runs automatically; everything else needs Apply).
- Load - three checkboxes, all checked by default: Ballast, Laden, Unknown. Ballast = empty repositioning; Laden = carrying cargo; Unknown = load state not determinable for that leg. Filtering to Laden only gives genuine cargo-carrying transit times (the figure relevant to a freight estimate); Ballast only shows repositioning speed, usually faster because the ship rides higher and burns less.
- DWT min / max - two number boxes (
#mindwt,#maxdwt) bounding deadweight tonnage; blank = no bound. Use this to compare like-for-like: a Capesize and a Handy don't steam the same lane at the same speed, so a clean estimate filters to your vessel's size class. - From / To date - an ISO date range (
#since/#until) applied to the departure date. Blank = all history. Narrow it to look at recent conditions (e.g. last quarter) versus the full multi-year record. Note the upper bound is inclusive to end-of-day (23:59:59). - Segment row (
#seg, hidden until data loads) - a dynamically built set of checkboxes (.sg), one per trade segment present in the matched voyages (e.g. iron ore, coal, grain). It only appears when the current route actually has segment-tagged voyages, and it rebuilds whenever the segment list for the route changes. Use it to confirm you are reading transit times for the right commodity flow.
Why it matters: the headline transit number is only as honest as the cohort behind it. A charterer should pin Load=Laden and a DWT band around the candidate vessel before quoting laycan or steaming days.
The headline summary line and the route map
After a run, the summary line (#rt) condenses the route into one sentence, for example: Rotterdam (NLRTM) → Singapore (SGSIN) · Netherlands → Singapore · 8,431 nm sea route · ~26 days at ref speed · 1 bunker/transit stop merged into cargo legs. Read it left to right:
- Port names + LOCODEs in accent colour, then the country→country pair.
- Distance + method - the computed nautical miles, tagged either
sea routeorgreat-circle. This tag is the single most important thing to check:sea route= routed realistically through canals/water;great-circle= a straight-line fallback that may understate true steaming distance. - "~N days at ref speed" - searoute's own duration estimate (
duration_hours) converted to days, computed at the library's fixed default reference speed, not your ship's actual speed. Treat it as a "how long should this take" benchmark and compare it against the observed Avg/Median in the KPI cards - the gap is where waiting, weather, congestion and slow-steaming hide. This only appears on a true sea route; it is suppressed when the great-circle fallback fires. - "N bunker/transit stop(s) merged into cargo legs" - appears only when the macro-collapse actually folded intermediate stops into these voyages (
transit_collapsed> 0). It is your transparency flag that the transit days include time the ships spent passing through (and waiting at) merged waypoints, not just clean point-to-point steaming.
Below the chips, the route map (#routemap, Leaflet/OpenStreetMap, ~300px tall) is hidden until a route with coordinates loads. It draws:
- A green circle marker at the origin and a pink circle marker at the destination, each with a hover tooltip showing the port name and LOCODE.
- A blue route line. A solid line is the real searoute waypoint polyline (curving through canals and around landmasses); a dashed line (
6,6) means the great-circle fallback - a visual echo of the distance tag. If no waypoints exist it falls back to a straight origin→destination segment.worldCopyJumpis on, so trans-Pacific lanes that cross the anti-meridian (e.g. Vancouver→Yokohama) render continuously instead of wrapping awkwardly. The map auto-zooms to fit the route.
What to look for: a dashed line plus a great-circle tag is your cue to treat the distance/days as a rough floor - the real sea route is at least that long and probably longer.
The route line prefers reality. When a vessel has actually sailed this O→D pair, the map draws that ship’s real track (gold) instead of an idealised line - so it follows the navigable channel and never crosses land, even on short or harbour-to-harbour hops where a generic sea-route graph would otherwise draw a straight line through a peninsula. Hover the gold line for the example ship and date; if no vessel has sailed it yet, the page falls back to the sea-route (or, as a last resort, a dashed great-circle). The header then also shows “~N nm actual” - the distance that example ship really steamed - next to the idealised figure.
The six KPI cards
The card row (#cards) summarises the matched-voyage cohort. Each card shows a big value, a label, and a unit; a missing value renders as an en-dash. All six are computed over the filtered legs:
- Voyages - the count of legs in the cohort (
n). This is your sample size; everything else on the page should be read in light of it. A two-voyage average is anecdote, not a statistic. - Avg transit (days) - mean sea-days across the cohort. Sensitive to outliers (one storm-delayed voyage drags it up).
- Median (days) - the middle voyage. For a small or skewed sample the median is the more trustworthy "typical" duration; compare it against Avg to gauge skew.
- Fastest (days) - the minimum observed transit. Roughly the best-case at full speed and good weather.
- Slowest (days) - the maximum observed transit. The spread between Fastest and Slowest is the lane's variability; a wide spread signals seasonal weather, congestion, or slow-steaming behaviour to budget for in laycan.
- Avg speed (kn) - average implied speed, derived as
distance / (transit_days × 24). Because it uses the reference distance, it is a sanity check on the cohort: if it reads, say, 16 kn for a Capesize lane, the underlying voyages probably include merged waiting time or a too-short distance, not genuine fast steaming.
Tonnage band, weekly and monthly seasonality breakdowns
Three sections slice the same cohort by different dimensions. Every table shares the columns Voyages, Avg (d), Median (d), Min (d), Max (d), Avg speed/kn. Numeric cells are right-aligned; a missing value shows an en-dash.
- By tonnage band (
#tb_band) - one row per DWT band present (ordered perROUTE_DWT_BANDS, with Unknown last for legs whose DWT is missing). This is the most decision-relevant table: it isolates how your ship class actually performs on the lane, rather than blending a Handy and a Capesize into one misleading average. If your candidate vessel's band is absent, there is no like-for-like history - widen the date window or treat the figure as indicative. Shows "No voyages match." when the filters exclude everything. - Week by week (depart week) - seasonal trend (
#tb_week+ bar chart#ch_week) - one row per ISO week of departure (e.g.2025-W14), in a scrollable table. The bar chart above plots Avg transit days per week; each bar is labelled with the value and its sample size as· n=…, so you can immediately discount a tall bar built on one voyage. Use this to see how a lane's transit drifted over real calendar time - lengthening bars across winter weeks flag seasonal weather slow-downs. - By month of year (seasonality, all years pooled) (
#tb_mon+ bar chart#ch_mon) - twelve potential rows (Jan…Dec), pooling every year together to expose a repeating annual seasonality pattern rather than a one-off trend. Same bar chart with value +n=labels. A charterer planning a winter North-Atlantic or monsoon-season Indian-Ocean fixture reads this to anticipate the typical seasonal day-penalty. Both charts show "No data" when the cohort is empty.
Cross-read tip: the weekly chart answers "what has this lane been doing lately?"; the monthly chart answers "what does this lane always do at this time of year?" Diverging answers (e.g. recent weeks fast but the month historically slow) are exactly the kind of signal worth pricing in.
Round-trip economics calculator
This section (#rt_cards, #rt_tb) is a distinct tool from the one-way analysis above. It pairs consecutive cargo macro-legs where leg-1 is O→D and the very next leg for the same ship is D→O - one observed round trip - and is served by /api/round_trip. It runs automatically alongside the main analysis, but the two cost inputs are optional and need the Compute button:
- Hire rate $/day (
#rt_hire, e.g. 14000) and Bunker $/t (#rt_bunker, e.g. 580). Supply both to unlock the cost and break-even outputs. Leave them blank and you still get days, fuel and CO₂. - Summary text (right-aligned) reads "
N round-trips observed" so you know the sample behind the medians.
The summary cards show: Round-trips (count), Median total days, Median sea days, Median fuel (t), Median CO₂ (t), and RT distance (nm, the full there-and-back). When you supply hire and bunker, two more appear: Med. voyage cost (in $) and Break-even ($/t). Fuel uses each ship's uploaded consumption figures (cons_at_sea/cons_at_port) when available, otherwise segment defaults; break-even is the freight rate at which the round trip washes its face, computed at 90% utilisation.
The per-trip table (up to 50 rows) lists each observed round trip: Ship (linked to its /ship/IMO page), IMO, Depart and Return timestamps, Total (d), Sea (d), Port (d) (waiting + berth time - the part the one-way transit figure excludes), Fuel (t), CO₂ (t), Voyage cost, and Break-even $/t. When no paired O↔D round trips exist yet it shows "No paired O↔D round-trips observed yet."
Why it matters: a one-way transit tells you steaming time; the round-trip block tells an owner or operator the true economic length of the employment (laden out + ballast back + port time) and the freight floor below which the voyage loses money. The Port (d) column is the eye-opener - it quantifies how much non-steaming time the lane really swallows.
The Voyages table (the raw evidence)
The final section Voyages (N) (#tb_legs, count in #c_legs) is the audit trail behind every aggregate above - the individual legs, sorted by departure date, capped at 1,000 rows. Columns:
- Ship - vessel name, a blue link that opens that ship's
/ship/IMOpage in a new tab (falls back to the IMO if the name is missing). - IMO - the vessel's IMO number.
- Departed / Arrived - UTC timestamps trimmed to
YYYY-MM-DD HH:MM. - Transit (d) - sea-days for that leg (the merged total if it was a macro voyage).
- DWT - the vessel's deadweight, thousands-formatted.
- Segment - the trade/commodity tag (or
-if untagged). - Load - Ballast / Laden / Unknown for that voyage.
- Speed (kn) - implied speed for the leg (reference distance ÷ its transit time).
How to use it: when an average looks off, scan this table to see whether it is driven by one anomalous voyage, by a mix of laden and ballast legs, or by a wide DWT spread. Click through to a suspicious ship to inspect its track. If the route/filters yield nothing it shows "No voyages for this route/filters yet." - a prompt to relax the Load, DWT or date filters rather than a sign the lane is wrong.
Seasonal advice and the cargo-lane bridge
When a researched pair has real voyage history, two chips appear under the header. 📅 Historically fastest/slowest months reads the by-month transit medians (only months with enough observed voyages count) and names the fastest and slowest months with their medians and sample sizes - a first seasonal routing aid that sharpens every month as the lane’s history deepens. 🚢 Active cargo lane appears when the pair is an observed trade lane: it shows the dominant tonnage band, today’s indicative lane rate in $/day, and a one-click jump to the full lane history page. Every query you run also accrues into the route-intelligence layer that future per-owner routing advice will draw on.