Plan: POI search (nearest harbour, fuel berth, anchorage; places by name)¶
Status: proposed 2026-10-25, second version the same day with the owner's decisions (below); not started. Phase 2 of the OpenStreetMap places (ADR 013); it was in the OSM places plan and got lost when that plan was deleted on 2026-10-19. Deleted once built; the behaviour then moves into a new spec docs/specs/server/poi.md, the sailing area into maps.md § Coverage and ingestion.md § OSM places.
Every proposal below lists the options considered, their trade-offs (cost, data quality, performance, privacy, complexity, safety) and a recommendation with its reason. Numbers marked measured come from the local import of 2026-10-19 (version 20261019T211937Z, 21 countries), from Geofabrik's download pages and taginfo (data of 2026-10-24), or from a trial export of Norway on 2026-10-25; numbers marked estimate are extrapolations and say how they were made.
Decided 2026-10-25 (owner)¶
- The Atlantic, the North Sea and the Baltic now, not the Mediterranean first. The area of the places (and so of POI search) is exactly the beta map area, and the beta map area covers exactly the Mediterranean, the Atlantic, the North Sea and the Baltic. Both come from one shared definition, so they can't drift apart. Section The sailing area.
- Bays are offered as anchorages, not only shown next to the tagged anchorages: filtered (no gulfs, no lake bays), measured fairly, and labelled so nobody takes an unchecked bay for a checked anchorage. Section Bays as anchorages.
Why¶
The places for track names are already in our own PostGIS (osm_places, refreshed with bin/rails osm:refresh), with more kinds than naming uses. A sailor's everyday questions are about those places: where is the nearest marina, where can I take fuel, which bay nearby gives shelter, where is "Cala Corsara". Answering them costs one SQL query and sends no position to anyone else.
What exists today¶
OsmPlaces::Build (build.rb, KIND) keeps every named place of these kinds, first match wins:
| Kind | OpenStreetMap tags | Count (2026-10-19, measured) | Geometry | Used by |
|---|---|---|---|---|
harbour |
seamark:type=harbour/marina, leisure=marina |
3811 | 2024 points, 1523 lines, 264 areas | naming |
anchorage |
seamark:type=anchorage |
340 (233 of them in the Balearics) | 321 points, 17 lines, 2 areas | naming |
bay |
natural=bay |
8871 | 7994 points, 334 lines (319 closed), 543 areas | naming |
cape, town, city |
nodes only | 6737, 6126, 668 | points | naming |
island, islet |
areas | 1272, 5054 | areas | naming (shores) |
mooring, harbour_basin |
seamark:type=… |
293 (40 of them buoys), 54 | nothing yet | |
fuel |
waterway=fuel |
139 | nothing yet | |
slipway |
leisure=slipway |
759 | nothing yet |
- Columns:
osm_type,osm_id,kind,name,name_en,tags(all OpenStreetMap tags, so phone, website and operator are there),geom(EPSG 4326),area_m2(areas only). Indexes: GiST ongeom,kind, and a trigram GIN index onlower(name)(pg_trgmis enabled;unaccentis not). - Geometry depends on the export, not on the map.
osmium exportturns onlyplace=island/isletand anything taggedarea=yesinto areas (refresh.rb,EXPORT_CONFIG); multipolygon relations are areas anyway. So a bay drawn as one closed way is a line today (319 bays), a bay drawn as a relation or witharea=yesis an area (470 + 73), and most bays are a single node (7994). The same holds for marinas: 1523 harbours are lines. - Size of the import: 34,124 places (63 MB with indexes), 50,211 shore pieces (32 MB), 78,805 coastline pieces (42 MB); 3.72 million coastline and shore vertices.
- Countries: the 21 Mediterranean and Adriatic extracts (
OsmPlaces::Refresh::COUNTRIES), about 11.1 GB of downloads. Spain, France and Morocco bring their Atlantic, Channel and North Sea coasts along: about 1000 bays, 770 harbours and 29 anchorages of the import lie there (measured with a rough box test, so a few inland towns of France count too). - A refresh is a Solid Queue job on the worker VM (
osm_places_refresh_job.rb, queuebackground,limits_concurrency … duration: 3.hours); it took about an hour on the 1-OCPU app VM (ADR 013, consequences). - Naming uses bays already: a bay within 1.5 NM of a position names it, measured to the bay's bounding box (0 inside), and inside several the smallest wins (
surroundings.rb,Feature#rank). That is why a position off Sorrento (40.655 N, 14.36 E, no harbour within 1 NM) is named "Gulf of Naples" (checked withSurroundingson the local import). Naming wants gulfs; anchorage search doesn't. The filters below are for POI search only and leave naming alone.
Examples¶
Each example runs in poi_examples_test.rb on the real tables, with the real OpenStreetMap objects as points or as simplified outlines (stub helpers osm_area and osm_way in osm_places_stub.rb). The tests assert today's answer; the target is in each test's comments and here. The numbers in this table are what the full import of 2026-10-19 answers (the simplified outlines in the tests differ by a few hundredths of a mile).
| # | Question | Today's answer | Target | What it shows |
|---|---|---|---|---|
| 1 | Nearest harbours from the entrance of Split harbour (43.5005 N, 16.4365 E) | ACI Marina Split 0.32 NM, Jedriličarski klub Labud 0.34, Zapadna obala 0.36, Nautika centar Nava 0.38 | ACI Marina Split first, at 0.21 (its outline) | Works. In a town, sailing clubs and quays crowd the list; the marina should lead. ACI Marina Split is an outline in OpenStreetMap: to the outline it is 0.21 NM, to its middle 0.32. |
| 2 | Nearest fuel berths from Split | INA Split-obala 0.37 NM, INA Trogir-marina 8.34, INA Milna-marina 10.41 | Same | Works. A road filling station (amenity=fuel alone) is rightly not imported; amenity=fuel with boat=yes is a fuel berth but isn't in the filter yet. |
| 3 | Nearest anchorage off La Maddalena (41.19 N, 9.40 E) | Fetovaia on Elba, 98 NM | Cala Villamarina 0.32 (bay), Cala Levante 0.57, Cala Corsara 3.5 | Tagged anchorages are rare outside the Balearics. The bays nearby are what a sailor means. |
| 4 | Search "Corsara" near La Maddalena | Cala Corsara (bay), Punta Corsara (cape) | Same | Works. |
| 5 | Search "palmizana" from Hvar | Palmi (a town in Calabria, 290 NM away) first, then Luka Palmižana, then ACI marina Palmižana | The two Palmižana places 2 NM away first, Palmi last | Broken: no accent folding (ž ≠ z), and distance only breaks ties. |
| 6 | Nearest anchorage or bay off Sorrento (40.655 N, 14.36 E) | Golfo di Napoli 0.00 (836 km², the boat is in it), Golfo del Pecoriello 1.85, la Chiaia 2.81, Gesiglione 3.49, Golfo di Salerno 3.80 (1303 km², behind the Sorrento peninsula) | Golfo del Pecoriello 1.85, la Chiaia 2.81, Gesiglione 3.49 | Gulfs mapped as areas. Their area is in area_m2 already: one threshold removes them. |
| 7 | Nearest anchorage or bay off Soverato (38.70 N, 16.60 E) | Golfo di Squillace 9.98 (a node 17.6 km off the coast), Golfo di Sant'Eufemia 26.38 (the Tyrrhenian side of Calabria), Baia di Sant'Irene 28.06 | "No anchorage within 25 NM" | Gulfs mapped as a node: no area to filter on. The node has no land within 1 NM in any direction. |
| 8 | Nearest anchorage or bay from Bar (42.0935 N, 19.087 E) | 97 within 25 NM, 15 of them inland: on Lake Skadar behind the coastal mountains (Smokvica 9.94, Liman Šitar 11.29, …) and on reservoirs in Albania | The other 82 only | Lake bays look like sea bays. In Norway 3276 of the 10,664 named bays (31 %) lie more than 5 km from any coastline (measured, trial export). |
| 9 | Nearest anchorage or bay inside Paroikia bay, Paros (37.0862 N, 25.1402 E) | Όρμος Παροικιάς 0.30, Όρμος Αλυκή 5.26, Alyki bay 5.48 | Όρμος Παροικιάς 0 ("you are in it"), Όρμος Αλυκή 5.26 once | A bay drawn as a closed way is a line, so inside it the distance is to its shore. Όρμος Αλυκή (a way) and Alyki bay (a node inside it) are the same bay. |
Proposal¶
1. Two services next to OsmPlaces::Lookup¶
OsmPlaces::Nearby.call(lat, lon, kinds:, within_nm:, limit:): candidates bygeom <-> pointfrom the GiST index, then the exact distance on the sphere (ST_Distance(geography); 0 inside an area). Ordered by distance (with the rules of sections 2 and 3), at mostwithin_nm(default 25 NM), so the open sea says "none within 25 NM" instead of a port 73 NM away.OsmPlaces::Search.call(query, near:, limit:): accent-folded trigram match onnameandname_en, ranked by similarity and distance together, for examplesimilarity − log10(1 + NM) / 4, so a near match nearby beats an equal match far away. Example 5 is the test. Search finds every kind, lake bays and gulfs included: a name is a name.
| Option | Trade-offs |
|---|---|
| A. Two services on the PostGIS tables (recommended) | One query each, milliseconds with the GiST and trigram indexes; no outside service, no position leaves our servers; same tables as naming. Ranking rules live in SQL and Ruby we test with real examples. |
| B. Nominatim / Photon (own instance) for search | Better text search (address parsing, languages), but a second database of Europe (tens of GB) and a service to run; overkill for place names. |
| C. Public Nominatim / Overpass | No work, but every query sends the user's position or query outside (privacy page entry), rate limits, and Overpass already failed us (ADR 013). Rejected. |
Ranking for search, options: similarity only with distance as a tie-break (today, example 5 fails); distance only within a radius (misses a place searched by name further away); a combined score (recommended: one formula, tested on examples 4 and 5; the weight is a single constant to tune).
2. Bays as anchorages¶
The owner decided that bays are offered as anchorages. This section says which ones, how they are measured and how they are labelled.
2.1 Which OpenStreetMap tags count¶
| Tag | In the import as | In "nearest anchorage"? | Label | Why |
|---|---|---|---|---|
seamark:type=anchorage |
anchorage |
Yes, unless seamark:anchorage:category is explosives, quarantine, tanker, deep_water or sea-plane (1 such in the import: explosives) |
Anchorage | Mapped as a seamark, often after a chart; still not checked by us. Ship anchorages and roads are no place for a small boat. |
natural=bay (node) |
bay |
Yes, after the filters of 2.3 | Bay, not a checked anchorage | 7994 of 8871 bays are nodes: no size, so the filters must work on the position alone. |
natural=bay (area or closed way) |
bay |
Yes, after the filters; over 100 km² never | Bay, not a checked anchorage | 862 bays; closed ways become areas with the import change in section 4. |
natural=bay + seamark:type=anchorage |
anchorage (first match wins) |
Yes | Anchorage | 27 in the import. |
seamark:type=mooring with seamark:mooring:category=buoy |
mooring |
Option: as "Mooring buoys" in the same list | Mooring buoys | 40 in the Mediterranean import. Often paid concession fields (Croatia), a real alternative to anchoring. Recommended for a later step: the category is missing on 245 of 293 moorings, so most of them can't be told from quays and walls. |
leisure=marina, seamark:type=harbour/marina |
harbour |
No, they are harbours | Marina / Harbour | Separate list ("nearest harbour"). |
natural=strait, place=sea, natural=fjord |
not imported | No | Not anchorages. The Gulf of Lion, for example, is not a natural=bay in OpenStreetMap and is not in the import; the Gulf of Sidra (61,705 km²) is. |
2.2 What the bays look like (Mediterranean import, measured)¶
- Geometry: 7994 nodes, 543 areas (470 relations, 73 ways with
area=yes), 334 lines (closed ways withoutarea=yes, 319 of them closed rings). - Size of the 862 bays that have an outline (areas, and closed lines turned into areas for counting): under 1 ha 95; 1–10 ha 248; 0.1–1 km² 214; 1–10 km² 134; 10–100 km² 83; 100–1000 km² 67; over 1000 km² 36. Up to 10 km² they are coves and anchorage bays (Rade de Villefranche-sur-Mer 3.4 km², Brguljski zaljev 4.8, Uvala Stara Baška 2.3, Luka Tiha 1.6). From 10 to 100 km² they are large bays, some of them classic anchorage bays (Badia de Pollença 63 km², Paški zaljev 30, Zaljev Vela Luka 17), some ports (Golfo di Olbia 60, Ría de Ferrol 25). Over 100 km² they are gulfs (Golfo di Napoli 836, Saronic Gulf 2914, Kvarnerić 1394, Gulf of Taranto 13,659).
- Distance of each bay to the sea coast (mainland coastline, shores of named islands and named islets; the import drops the coastline of islets, so a cove on an unnamed or small islet looks further out than it is): nodes under 500 m 7570 (94.7 %), 0.5–1 km 149, 1–2 km 87, 2–5 km 96, 5–20 km 56, over 20 km 36. The far ones are lake bays (Lago Maggiore: Golfo di Laveno, Golfo di Angera; Lake Skadar; the lakes of the Forêt d'Orient in Champagne, 110 km inland), gulf labels in open water (Golfo di Squillace 17.6 km, Thermaic Gulf 19.5 km, Gulf of Gabès 18.1 km), river bays (Krka above Šibenik) and, wrongly, coves of islets (Cala d'Acqua in the La Maddalena archipelago, 2.1 km).
- Duplicates: 25 bay nodes lie inside a bay area of the same name (Osorski zaljev); more lie inside a bay area of another name or language (Alyki bay inside Όρμος Αλυκή, example 9). 2118 bay nodes lie inside some bay area: coves inside gulfs.
2.3 Which bays are no anchorage, and how to tell¶
| Problem | Example | Signal | Options | Recommendation |
|---|---|---|---|---|
| Gulf mapped as an area | Golfo di Napoli 836 km² (example 6) | area_m2 |
(a) hard limit; (b) rank large bays lower; (c) offer the gulf's coves instead (bays inside it) | (a) over 100 km² never, they stay bays for naming and search. 10–100 km² are offered but labelled "large bay" with the distance to their outline. Reason: the 100 km² line separates gulfs from bays in the data above; a lower limit (10 km²) would lose Badia de Pollença and Paški zaljev, a higher one lets the Gulf of Naples in. Cost: none (the column exists). |
| Gulf mapped as a node | Golfo di Squillace (example 7), Golfo di Policastro, Κόλπος Κεγχρεών | No area. Its label lies far from land | (a) distance from the label to the coast; (b) shelter: in how many of 16 directions land lies within 1 NM of the label; (c) name words ("Golfo", "Gulf", "Κόλπος", "Körfezi") | (b), with (a) as part of it. A node with land in fewer than 9 of 16 directions (a label next to a straight coast gives about 8, half the directions) is not offered. Name words are rejected: Golfo di Arzachena, Golfe de Murtoli and Golfo della Lacona are inlets and coves of a few kilometres. |
| Bay of a lake, a reservoir or a river | Lake Skadar (example 8), Lago Maggiore, 31 % of Norway's named bays | The label lies on the land side of the sea coastline | (a) distance to the coastline over N km; (b) side of the coastline: OpenStreetMap coastline ways have the land on their left, so the side of the nearest coastline way tells sea from land; (c) point in the sea polygons (osmdata water polygons, as the depth build uses) | (b). Checked on today's tables with the mainland coastline: Golfo di Laveno, Golfo di Angera, Liman Šitar, Smokvica, Anse de Jolivet, Baie de Grésine → land; Golfo del Pecoriello, Baia di Ieranto, Luka Bar, Uvala Bigovica, Baie de Saint-Raphaël → sea. It must use the raw, directed coastline of all islands (Badia de Palma came out "land" because the shores of named islands are not in osm_coastline and their direction is lost). (a) fails near the coast (a lake 2 km inland); (c) is exact but a 900 MB download for every refresh. Trade-off: river anchorages above the coastline (Krka to Skradin, the Gironde above the mouth) are dropped too; they stay findable by name. |
| Open roadstead, ship anchorage | Barsko sidrište (Bar's roads, 5.9 km², land in 9 of 16 directions, open SSW–NNW) | Shelter, open sector, name | Shelter threshold; show the open sector | Show the open sector for every bay; the threshold of 9 keeps it in, which is honest: it is an anchorage, just an open one. |
| Fjord-sized bay, deep water | Norwegian fjords mapped as natural=bay |
Area (when mapped as one); depth | Area limit; depth (2.5) | The area limit catches fjords mapped as areas. A fjord drawn as a node passes the shelter test (land on both sides). Depth would catch it; not in the first step (2.5). Stated as a known gap for Norway. |
| Protected seabed, no-anchoring zones | Posidonia zones in France and Spain, the La Maddalena national park (Cala Corsara lies in it) | boundary=protected_area, seamark:type=restricted_area |
(a) ignore; (b) import protected areas and warn; (c) hide bays inside | (b) as a later step, never (c): rules differ by zone, season and boat size, and a hidden bay is worse than a warned one. Until then the label says local rules are not checked. |
The import computes these once, per bay, not per query: coast_m (distance from the label to the nearest coastline, islets included), sea (label on the water side), shelter (0–16) and open_to (the open directions, for example "S–SW"). New columns on osm_places, filled only for bays and anchorages.
| Where to compute | Trade-offs |
|---|---|
In OsmPlaces::Build, from osm_features before the coastline is reduced (recommended) |
All coastline ways with their direction and all islets are there; no extra download; the per-query SQL stays a plain index lookup. Cost: import time. Measured on today's tables on the Mac: the coast distance for all 8871 bays took 32 s; the 16-ray shelter test for 19 bays took 0.1 s, so about 40,000 bays (estimate for the new area) take minutes, also on the worker VM. |
| At query time | No import change, but 16 ray tests per candidate on every request: slow for a map layer, and the directed coastline is gone by then. |
| On the worker VM, in the depth build | It has the water polygons and the depth, but it would couple the Rails import to the tile build's schedule and files. Later, for depth (2.5). |
Measured shelter on today's tables (16 directions, 1 NM, land = mainland coastline, named island shores and named islets; land in N directions, open to): Baia di Ieranto 16; Cala Villamarina 16; la Chiaia 16; Golfo del Pecoriello 15, open NW; Luka Bar 15; Cala Levante 14, open ESE–SE; Uvala Nečujam 14, open N–NNE; Golfo di Arzachena 13, open N–NE; Uvala Bigovica 13, open WSW–WNW; Baie de Saint-Raphaël 11, open SSE–WSW; Cala Corsara 10, open ESE–SW; Luka Tiha 10; Uvala Stara Baška 10; Barsko sidrište 9, open SSW–NNW; Badia de Palma 0 (its label is 4.7 km from the shore); Golfo di Napoli, Golfo di Salerno, Golfo di Squillace 0; Golfo di Laveno 0 (a lake: no sea coastline).
2.4 Order of the list¶
| Option | Trade-offs |
|---|---|
| Distance only, after the filters; shelter breaks ties within 0.5 NM; anchorages and bays in one list (recommended) | Predictable ("the nearest"), easy to explain next to an unchecked label. A cove 0.4 NM further but sheltered from three more directions can come second. |
| A weighted score (distance and shelter) | Puts sheltered coves first, but the order can't be explained in one sentence, and shelter from the coast's shape is not shelter from today's wind. |
| Wind-aware (shelter against the forecast wind direction) | What a skipper really wants. Needs a forecast per request: Open-Meteo is already a processor and gets positions rounded to 28 km for wind (privacy page), but live positions for a forecast are a new purpose to add to the privacy page, and a wrong forecast makes a wrong recommendation. Later, if at all. |
| Tagged anchorages before bays | The tags are rare (340, two thirds in the Balearics) and no better checked than bays; a tagged anchorage 10 NM away before a cove 1 NM away is worse. Rejected; the label tells them apart. |
2.5 Depth¶
| Option | Trade-offs |
|---|---|
| No depth in the first step (recommended) | Honest: we don't show a number we can't stand behind. Fjords drawn as nodes stay in (2.3). |
| Depth at the label from our depth layer | The layer is vector tiles in R2, not a table: the import would have to read PMTiles. Near the shore the layer is estimated from the coastline (maps.md § Depth, "Depths are approximate and not for navigation"); EMODnet's 115 m cells are coarser than most coves. A "4 m" next to a cove reads like a promise. |
| Depth per bay written by the depth build (a CSV next to the tiles) that the import joins | Uses the best data we have, as a filter only ("deeper than 30 m at the label" → ranked down, never shown as a number). Couples two pipelines; worth it for Norway's fjords. Recommended as a later step. |
2.6 Distance to a bay¶
| Measure | Example 6 (Golfo di Napoli, boat inside) | Example 9 (Paroikia, boat inside) | Trade-offs |
|---|---|---|---|
| Nearest point of the outline, 0 inside (recommended) | 0.00 | 0 (after the import change), 0.30 today | Answers "how far until I'm in the bay"; the same rule as harbours as areas and as naming (0 inside). For a large bay "0" says nothing about where to anchor, hence the 10–100 km² "large bay" label and the 100 km² limit. |
| Centroid | (can fall on land or outside a C-shaped bay) | Rejected: a centroid on the peninsula is a point to steer for. | |
Label point (ST_PointOnSurface) |
7.02 NM | about 0 (the label happens to lie where the boat is) | Always inside, but an arbitrary spot: 7 NM to "the Gulf of Naples" while in it is wrong. |
OpenStreetMap label node (relation member label) |
Rarely mapped. |
For nodes (90 % of bays) all measures are the same: the distance to the node, which a mapper usually placed in the middle of the cove. Distances are straight lines: Golfo di Salerno is 3.80 NM from the boat in example 6 across the Sorrento peninsula and about 5.5 NM by sea round Punta Campanella; Fiordo di Crapolla, a cove on the far side, is 3.88 NM straight and roughly twice that by sea (estimate from the chart). Distances by sea are out of scope (a routing problem over the coastline); the 100 km² limit removes the gulf, the cove stays. Stated in the label ("straight line").
2.7 Duplicates¶
A bay node inside a bay area is dropped from the anchorage list when it is the same bay: same name, or the area is at most 10 km² and has no other bay node inside (Alyki bay inside Όρμος Αλυκή). Inside several areas, the smallest wins (as naming does). Options considered: dedupe by name only (misses name:en vs. Greek, example 9), by distance only (merges neighbouring coves). Done at import time, so queries stay simple.
2.8 Labels and safety¶
SailEver shows these places next to a track, on a map or on a watch: navigation-adjacent. A skipper must never take an unchecked bay for a checked anchorage.
- Each bay: "Bay · not a checked anchorage · open to S–SW · 1.8 NM (straight line)". A tagged anchorage: "Anchorage (OpenSeaMap) · open to SE". A large bay: "Large bay", and inside it "you are in this bay".
- Under every list, one line (like the depth note under the trip map): "From OpenStreetMap, not checked by SailEver. Not for navigation: check the chart, the pilot book, the weather and local rules (protected seabed, no-anchoring zones)."
- Never the words "safe", "sheltered", "good holding" or "recommended". "Open to" comes from the shape of the coast and says so on hover.
- The terms' disclaimer lists "depths, place names, wind and tide data" (
terms.html.erb); it gets "harbours, fuel berths, anchorages and bays found by SailEver" with the first surface. Whether that is a new terms version (everyone asked again, auth.md § Terms) is the owner's call; recommended: yes, it widens what the disclaimer covers. - On the watch the line doesn't fit: the watch shows "Bay (unchecked)" and the open sector, and the first use shows the full note once (section 6).
Options considered for the label: no label (rejected: implies we checked), hiding bays behind a switch (rejected by the owner's decision), a warning icon only (too easy to ignore). Recommended: the words above, in all seven languages.
3. Harbours: marinas first¶
Inside one harbour, leisure=marina and seamark:type=marina rank before sailing clubs and quays at about the same distance (within 0.2 NM); harbour basins and moorings are folded into the harbour they lie in. Options: pure distance (example 1 today: a club at 0.34 between the marina and the quay), marinas always first (a marina 5 NM away before the town quay 0.3 NM away: wrong), marina first within a small band (recommended; one constant, tested on example 1).
4. Import changes (one osm:refresh afterwards)¶
| Change | Why | Cost / risk |
|---|---|---|
amenity=fuel with boat=yes counts as fuel |
Fuel berths tagged the "amenity" way (example 2) | One more filter; no inland stations because boat=yes is required. |
leisure=marina, seamark:type=harbour and natural=bay become areas (area_tags) |
Distance 0 inside, the outline counts (examples 1 and 9); area_m2 for every closed bay |
Naming measures to the bounding box and doesn't change. |
Bay columns coast_m, sea, shelter, open_to, and the duplicate flag (section 2) |
Anchorage filters | A few minutes of import time (estimate). |
Migration: the unaccent extension (it ships with Postgres) and an immutable f_unaccent(text) wrapper; the name index on lower(f_unaccent(name)) |
Example 5 | Index rebuild with every import anyway. |
The sailing area from the shared definition (section 5) instead of Build::REGION and Refresh::COUNTRIES |
Decision 1 | |
limits_concurrency of the refresh job from 3 to 8 hours |
The bigger import (5.5) | The advisory lock in Build#locked still keeps it to one import at a time. |
5. The sailing area: one definition for map, depth and places¶
5.1 Today¶
| Layer | Area | Where it is defined |
|---|---|---|
| Base map (the beta map area) | Box 11° W – 36.5° E, 30° N – 71.5° N, cut in five latitude bands; 46 GB, 19.5 million tiles (2026-10) | basemap/build.py WEST, EAST, BANDS; maps.md § Coverage; ADR 004 |
| Depth layer | The same box, sea only (water inside the coastline) | depth/build.py REGION; its test |
| Places | Features overlapping the same box, but only from the 21 Mediterranean and Adriatic extracts | build.rb REGION, refresh.rb COUNTRIES; ingestion.md § OSM places |
The box is written down three times in code and twice in specs, kept equal by comments only. The box already covers the Mediterranean, the Atlantic coast from Agadir (30.4° N) to the North Cape (71.2° N) and the west of Ireland (10.5° W), the North Sea and the whole Baltic (Gulf of Bothnia to 65.8° N, St Petersburg at 30.3° E). It also covers the western Black Sea and the Sea of Azov up to 36.5° E, the Russian Barents and White Sea coast west of 36.5° E, and all of inland Europe. It does not cover the Canary Islands, Madeira, the Azores or Iceland.
So the map already shows the four seas; the places cover the Mediterranean and the Atlantic and Channel coasts of Spain, France and Morocco only.
5.2 What the area must become¶
| Option | Map | Places | Trade-offs |
|---|---|---|---|
| A. The box stays; places for every country with a sea coast inside it (recommended) | No change | 21 + 19 extracts for the Atlantic, North Sea and Baltic (5.4), + Bulgaria, Romania, Ukraine for the Black Sea coast that is on the map | "Places area = map area" holds exactly: every coast the map shows gets places. The box is a little more than the four seas (western Black Sea, Barents coast), which costs 1.3 GB of downloads and about 5000 places (estimate) and harms no one. Inland lakes stay on the map (lake sailors; three beta tracks near Vienna). |
| B. A polygon of exactly the four seas plus a coastal strip, for map, depth and places | pmtiles extract --region with the polygon; smaller (inland Europe gone, estimate 20–30 GB instead of 46) |
As A without the Black Sea | Exactly the owner's words. But the trip map goes blank inland (lake tracks, the drive to the marina), the Worker still picks bands by latitude only, three pipelines change at once, and R2 savings are a few tens of cents a month (46 GB cost about US$0.55, maps.md § Data, backups, costs). |
| C. A plus the Atlantic islands (Canaries, Madeira, Azores) as extra boxes | Three small boxes more | africa/canary-islands (57 MB), europe/azores (17 MB); Madeira comes with europe/portugal (to be checked) |
The Canaries are a big sailing area (and the start of the ARC). Needs the tiles Worker to pick a file by box, not only by latitude (maps.md § Tile host), the basemap and the depth build to loop over boxes. A step of its own. |
Recommendation: A now, C as a separate step once the owner wants the islands (open question 2). B only if "exactly the four seas" is meant literally, and then for all three layers together.
5.3 One definition, so they can't drift¶
| Option | Trade-offs |
|---|---|
One JSON file, infra/area/sailing_area.json (new), read by all three; a checked copy for the server (recommended) |
The worker image gets it through one !area/ line in infra/.dockerignore; Python reads it directly. The server image is built from apps/server only (DEPLOYMENT.md, build context), so the server keeps apps/server/config/sailing_area.json as a copy, and a Rails test fails when the copy differs from the original (CI has the whole repository). Tests in the basemap and depth pipelines assert that their bounds come from the file. The specs name the file instead of repeating the numbers. |
| Constants stay where they are, a test compares them | Least change, but three formats (Ruby array, Python tuple, bands) and the country list still separate; drift is caught, not prevented. |
| The server image built from the repository root | No copy, but a bigger Docker context, a new .dockerignore at the root and a change to mise guard context; more risk than a copy with a test. |
| Generate Ruby and Python constants from the file | No copy at runtime, but a generator to keep and run. |
Contents of the file: the box (west, south, east, north), the basemap's latitude bands, the Geofabrik extracts (path and a one-line reason each), and the extracts deliberately left out with their reason ("landlocked", "only inland water in the box"). bin/rails osm:area_check (new) downloads Geofabrik's index-v1.json (every extract with its outline, 3.8 MB) and fails when an extract that overlaps the box is neither listed nor left out with a reason. That turns "the area changed but the country list didn't" into a failing check instead of silent gaps. It runs by hand when the area changes, not in CI (no network in tests).
5.4 Geofabrik extracts (sizes of 2026-10-24, measured)¶
Today: 21 extracts, 11.1 GB (largest France 4.8 GB, then Italy 2.1, Spain 1.4).
New for the Atlantic, North Sea and Baltic (option A):
| Extract | Size | Extract | Size |
|---|---|---|---|
europe/portugal |
406 MB | europe/norway |
1.3 GB |
europe/great-britain |
2.0 GB | europe/sweden |
896 MB |
europe/ireland-and-northern-ireland |
397 MB | europe/finland |
737 MB |
europe/isle-of-man |
6 MB | europe/estonia |
118 MB |
europe/guernsey-jersey |
4 MB | europe/latvia |
134 MB |
europe/belgium |
665 MB | europe/lithuania |
213 MB |
europe/netherlands |
1.3 GB | europe/poland |
2.0 GB |
europe/germany |
4.5 GB | russia/kaliningrad |
28 MB |
europe/denmark |
473 MB | russia/northwestern-fed-district (St Petersburg, Karelia, Murmansk) |
625 MB |
europe/faroe-islands |
7 MB |
19 extracts, 15.8 GB. The Black Sea coast on the map (option A): europe/bulgaria 168 MB, europe/romania 316 MB, europe/ukraine 841 MB (with Crimea); Turkey's Black Sea coast west of 36.5° E is in already. Together 43 extracts, about 28 GB per refresh.
Choices: great-britain + ireland-and-northern-ireland + isle-of-man + guernsey-jersey (2.41 GB) instead of united-kingdom (2.1 GB, without the Isle of Man and the Channel Islands, per Geofabrik) or britain-and-ireland (2.4 GB, coverage of the small islands not stated). Germany's 2757 marinas and 22,820 moorings (taginfo) lie mostly inland, judging by its short coast; since 2026-10-26 the import takes only its five coastal states (the whole-country extract has no MD5 at Geofabrik). The Spain extract has no Canary Islands (Geofabrik lists them separately), so option C needs africa/canary-islands.
5.5 Import cost¶
| What | Today | With option A | How it was found |
|---|---|---|---|
| Download | 11.1 GB, 21 files | about 28 GB, 43 files | Geofabrik, measured |
| Largest file on the worker's disk | France 4.8 GB | France 4.8 GB (Germany 4.5) | measured; one file at a time, so the worker's disk budget ("at most about 5 GB") holds |
| osmium | Norway: filter 8 s, export 7 s on an 18-core Mac; 55 MB filtered, 467 MB of GeoJSON lines | measured; on the 2-OCPU worker maybe ten times longer (estimate), still minutes | |
| Places (rows) | 34,124 | about 195,000 (+157,000 for the Atlantic, North Sea and Baltic, +5000 for the Black Sea) | estimate from taginfo counts per extract × the named share per kind of today's import (that method gives 31,600 for today's countries, 7 % under the real 34,124). Norway alone, measured: 48,530 places (10,664 bays, 30,211 named islets, 3670 islands, 2713 capes, 753 harbours, 271 fuel berths) |
| Coastline and shore vertices | 3.7 million | about 18 million | estimate: Norway has 228,028 coastline ways with 7.3 million vertices (measured); the new extracts have 468,000 coastline ways (taginfo), Norway's 32 vertices per way applied to all |
| Live tables | 137 MB | about 0.75 GB (places 360 MB, coastline and shores 370 MB) | estimate: today's bytes per row and per vertex × the counts above |
| Peak on the database VM during an import | about 3–5 GB (staging text and JSON of the features, the new tables next to the live ones) | estimate; the db VM has a 50 GB data volume, and nothing else can be added for free (infra/oci/README.md, Always Free budget). Check df there before the first run. |
|
| Duration | about an hour | about 2–4 hours | estimate: downloads ×2.5, the build's SQL scales with the coastline (×5) and with the islets taken off the coastline (166,594 island and islet outlines in Norway alone) |
| Places for the islets | 5054 named islets | The new extracts have 413,000 islets in all (taginfo: Norway 166,000, Finland 103,000, Sweden 66,000, Russia's northwest 41,000); Norway's are 18 % named (measured) | The islets matter only as land in the shelter test and for removing island coasts from the mainland; named islets are not used for naming. |
Risks and what to do about them:
- A deploy restarts the job from the first country (osm_places_refresh_job.rb). Three hours is a longer window. Options: don't deploy while osm:status shows a running import (recommended now, no code); keep each country's GeoJSON on disk so a restart skips finished downloads (later, if it bites; a few GB of disk: Norway's alone is 467 MB).
- The job's concurrency limit (3 hours) would expire mid-import; raise it (section 4).
- The worker's disk is shared with the depth build (about 20 GB while it runs) and the basemap (14 GB free needed). The import adds at most 6 GB; check free space before each download, as the basemap does (FREE_BYTES).
- Measure first: before the full run, OSM_COUNTRIES=europe/norway bin/rails osm:refresh locally (writes the local development database) gives the real row counts, sizes and build time of the hardest country.
5.6 Depth layer¶
The depth layer covers the same box today (depth/build.py REGION, sea only), so with option A it already matches: nothing to do. The smoke tests already include Den Helder and Marstrand, and the Nordic archipelagos are the slowest part of its build. With option C (Atlantic islands) or B (polygon) the depth layer must follow; that is a decision of its own (EMODnet covers the Canaries, Madeira and the Azores; more coastal cells to build, not measured).
5.7 Renaming tracks after the import¶
places:rename recomputes every track oldest first with its generated names cleared (rename.rb), a full TrackRecompute per track; typed names stay and no trip mail goes out. The naming cache is keyed by the import version, so new lookups use the new places at once.
| Option | Trade-offs |
|---|---|
| Full rename after the refresh (recommended now) | Simple, already built; also picks up changes in the Mediterranean. At beta size (tens of tracks) minutes. Names users already saw can change (better places, or a new nearer bay). |
Only tracks with a point in the new area or a position as a name (°) |
Faster at scale, leaves Mediterranean names alone. Needs a bounding-box filter in Rename. Recommended once there are thousands of tracks. |
| Lazy (on the next recompute or view) | No batch job; names stay stale until something touches a track. |
Expected effect at beta: tracks in the new seas get place names instead of positions; the three tracks near Vienna keep positions (Austria has no coast and stays out of the import).
6. Where it shows¶
| Surface | Value | Cost and risk | Privacy |
|---|---|---|---|
| Search box on the trip map | Jump to a place by name; fixes example 5 where users see it | Small: one endpoint, one input; no ranking of anchorages needed; low safety risk (a name, not a recommendation) | The query and the map's centre reach our server; add q, lat, lon to the filtered log parameters (filter_parameter_logging.rb filters none of them today) |
| Trip page: nearest harbour, fuel and anchorage for each stop and the trip's end | Shows the anchorage rules on real trips, where the owner can judge them; uses data already stored | Small; only after a trip, so little use at sea | No new data |
Watch API POST /api/v1/places/nearby: "nearest harbour / fuel / anchorage" on the watch |
The real use at sea | Largest: both watch apps (Garmin and Wear OS, parity), small-screen labels for unchecked bays, battery and connection at sea; highest safety exposure | The watch sends its live position: behind the consent gate, a new line on the privacy page, never stored. POST, not GET, so the position is not in the query string of the request logs |
| Map layer: harbours, anchorages and fuel berths from zoom 11 | Visible, browsable | GeoJSON per map view for up to 195,000 places, or baked into the tile host later; symbols must carry the "unchecked" distinction; OpenSeaMap's overlay already draws harbours and charted anchorages | The map's bounds reach our server (no user position) |
7. Privacy¶
- POI search answers from our own tables: no new outside service, nothing on the privacy page for search itself (privacy.md).
- The watch API is a new kind of request with a live position: consent gate on the watch, privacy page entry ("Nearest harbour on the watch: the watch sends its position; we answer and keep nothing"), no storage, positions kept out of logs (POST body, filtered parameters).
- Geofabrik and taginfo receive only our server's downloads, no user data. A wind-aware ranking (2.4) would send live positions to Open-Meteo: a new purpose, a privacy page change, not proposed now.
- No new table holds user data, so account deletion is unaffected.
Steps¶
| # | Who | Step | Done when |
|---|---|---|---|
| 1 ✅ 2026-10-25 | Claude | Examples 1–9 on the real tables (poi_examples_test.rb) |
Tests green |
| 2 ✅ 2026-10-25 | Owner | Decided: more seas now, bays as anchorages | This plan |
| 3 | Owner | Answer the open questions below | Order and area decided |
| 4 | Claude | The shared area file and its checks (5.3); Refresh countries and Build region from it; basemap and depth read it; spec texts point to it |
Tests green in all three pipelines; nothing on the map changes |
| 5 | Claude, owner | Local trial import of Norway; numbers into this plan | Measured rows, sizes, time |
| 6 | Claude | Import changes (section 4) and the bay columns; Nearby and Search with the ranking rules; examples 1–9 moved onto the services with the target answers |
Tests green, spec poi.md, ingestion.md updated |
| 7 | Owner | Check free disk on the db VM, deploy, osm-refresh (not deploying while it runs), then places-rename |
osm:status shows the new version and countries |
| 8 | Claude | The first surface (question 1), then the others one by one; terms wording with the first surface | Owner happy on three trips |
Open questions (owner)¶
- Which surface comes first: search box, trip page, map layer or the watch API? Recommendation: the search box first (smallest, fixes a visible bug, no safety claim, no new data), then the trip page (the owner judges the anchorage rules on real trips before anyone relies on them at sea), then the watch API (the most useful at sea, but two watch apps, the consent gate and the strongest need for trustworthy labels), the map layer last (OpenSeaMap's overlay already shows harbours and charted anchorages; the new value is bays and fuel). The trade-off: the watch API is what sailors would use most, and this order makes them wait two steps for it.
- The Atlantic islands (Canaries, Madeira, Azores): part of "the Atlantic"? They are outside today's box. Recommendation: yes, but as a separate step after this one (option C), because the tiles Worker, the basemap and the depth build all change.
- "Exactly the four seas": keep the box, with the western Black Sea and the Barents coast as a harmless extra (option A, recommended), or cut map, depth and places to a polygon of the four seas (option B, blank inland map)?
- Terms: a new terms version for the wider disclaimer (2.8)? Recommendation: yes, with the first surface.