Map tiles¶
| Rails | Tiles (Cloudflare) | |
|---|---|---|
| Status | ✅ | ✅ (live after the owner's first tofu apply and tile build) |
| Code | app/javascript/maps/base_layers.js, app/views/layouts/application.html.erb, config/application.rb (tiles_url), config/deploy.yml |
infra/tiles/worker.js, infra/depth/depth/, infra/basemap/basemap/, infra/places/places/, infra/worker/, infra/oci/tiles.tf, .github/workflows/tiles.yml |
Verified against code: 2026-10-20
Purpose¶
Every map on the website (the trip map, the mini maps on the trip cards, the track editor) draws its base map from SailEver's own tiles instead of OpenStreetMap's donated tile servers. The tiles are a monthly extract of the Protomaps basemap (OpenStreetMap data) for the sailing region, kept in Cloudflare R2 and served by a Cloudflare Worker. Why: ADR 004.
Pipeline at a glance¶
From source data to the pixels in the browser, in five steps. Each step is specified in the section named.
- Base map: a regional extract of the Protomaps vector tiles (built from OpenStreetMap) is cut in latitude bands and stored in R2; rebuilt monthly (Monthly update).
- Depth: EMODnet's bathymetry grid is cleaned against OpenStreetMap's coastline (0 m), refined to a fine grid with survey data blended in, and turned into depth bands, contour lines and "no depth data" areas, then into its own vector tiles (Depth).
- Island names: named islands and islets are taken from our own OpenStreetMap import and written as points into a third tile layer, with the island's area deciding from which zoom its name shows (Island names).
- Delivery: the Cloudflare Worker reads tiles from R2 by range request and serves them gzipped; the version is part of every tile URL, so Cloudflare's cache keeps them for 30 days and a new build never mixes with old tiles. A build goes live only after its smoke test (Tile host).
- Rendering: the page loads the three TileJSON files once and draws the layers in a fixed order (water, depth, land, coastline, the rest of the theme, labels) with protomaps-leaflet, so the depth ends exactly at the coastline; colliding labels are dropped, and zooms beyond the data are scaled (Base map).
Base map¶
| Element | Content |
|---|---|
| Base | Protomaps vector tiles, theme "light", English labels, drawn by protomaps-leaflet 5.1.0 (from unpkg, signed-in layout only, with an integrity hash). Data up to zoom 15; Leaflet zooms on to 18 by scaling. |
| Land and sea | Recoloured from the theme so land stands apart from the pale sea by hue: land #ebd28c (warm sand), land cover in sand tints (forest #dccb87, scrub #e0cd88, grassland and farmland #e4cf8a, barren #eed79c, urban #ded3b6, glacier #f4f4f0); sea #e5f3fa; coastline #8a7443, 0.5 px up to zoom 7, 0.8 px from zoom 8. Parks, roads, buildings and labels keep the theme's colours. |
| Depth | Depth bands and contour lines between the sea and the land (Depth), on every map |
| Island names | Every named island and islet in the sea (Island names), on every map |
| Seamarks | OpenSeaMap seamark tiles through tiles.sailever.com/seamark/… (not on the track editor) |
| Credit | See Credit. |
| Fallback | Without SAILEVER_TILES_URL, or when tiles.json doesn't answer, the map uses OpenStreetMap's raster tiles (tile.openstreetmap.org) and tiles.openseamap.org directly, credit "© OpenStreetMap contributors, OpenSeaMap", no depth; the browser console says "SailEver tiles unavailable, falling back to OpenStreetMap". Development and tests run this way. |
The page reads the tile host from <meta name="sailever-tiles"> (SAILEVER_TILES_URL, production https://tiles.sailever.com) and fetches tiles.json once per page.
Scale¶
Every map has a scale bar bottom left: the trip page map (owner, crew and the public page), the track editor, the trip replay, and the trip cards (at most 60 px wide, 8.5 px text); only the link-preview image has none. It shows nautical miles only (1 NM = 1852 m), at most 120 px wide on the large maps, rounded to 1, 2, 3 or 5 times a power of ten: "5 NM", "0.3 NM", "0.05 NM". It follows the zoom and the map centre's latitude (maps/nautical_scale.js, Leaflet's scale bar with its imperial line replaced).
Credit¶
maps/map_credits.js, components/_map_info. Leaflet's own credit line is removed.
- Every map (large maps, trip cards, the link-preview image): bottom right a short "© OpenStreetMap" (10 px, 8.5 px on the cards, white backing) linked to openstreetmap.org/copyright, so the licence's visible credit is always there. On the cards it sits right, the scale left, never touching.
- Large maps (trip page and public page, track editor, trip replay): next to it an "i" button in the style of Leaflet's zoom buttons (30 px white square,
leaflet-bar),aria-label"About this map". It opens the dialog About this map (closes with ✕, Esc or a click outside): - a caution box: "Not for navigation. Depths are approximate: near the shore they are estimated from the coastline, and hatched dark blue water has no depth data. Positions, speeds, wind and depths are for your logbook. Use official charts and your own judgement at sea.";
- the credits, each linked: Map "Protomaps · © OpenStreetMap contributors (ODbL)", Depth "EMODnet Bathymetry (ODbL)", Seamarks "OpenSeaMap", Wind "Open-Meteo (CC BY 4.0)";
- links to Privacy, Terms, Imprint.
English and German (web.map_info.*); other languages fall back to English. One dialog per page.
Coverage¶
Bounding box 11° W – 36.5° E, 30° N – 71.5° N: Portugal and Ireland to the Levant, the North African coast to the North Cape, so the Mediterranean, the European Atlantic coast, the North Sea and the Baltic. All zoom levels 0–15 (about 46 GB, 19.5 million tiles in 2026-10). Outside the box the map is empty, except at the lowest zoom levels, where one tile reaches far beyond it.
Tile host¶
tiles.sailever.com (Cloudflare Worker sailever-tiles, R2 bucket sailever-tiles, location Western Europe).
| Request | Answer | Cache-Control |
|---|---|---|
GET /tiles.json |
TileJSON 3.0.0 of the current version: tiles = https://tiles.sailever.com/v/<version>/{z}/{x}/{y}.mvt, zoom range, bounds, attribution |
public, max-age=3600 |
GET /v/<version>/<z>/<x>/<y>.mvt |
The vector tile, Content-Encoding: gzip; 204 when the extract has no tile there |
public, max-age=2592000, immutable (30 days) |
| Same, for a version that was deleted | The tile from the current version | public, max-age=3600 |
GET /seamark/<z>/<x>/<y>.png (z ≤ 18) |
OpenSeaMap's tile, fetched with the User-Agent "SailEver tiles (https://sailever.com; tiles@sailever.com)"; 204 when OpenSeaMap has none, 502 when it fails | public, max-age=2592000, immutable |
| Anything else | 404; other methods than GET/HEAD/OPTIONS 405 | — |
GET /depth.json |
TileJSON of the current depth build: tiles = https://tiles.sailever.com/depth/v/<version>/{z}/{x}/{y}.mvt, release (EMODnet DTM year), deepest (lowest contour level in metres, 10000; missing for builds before it), zoom 0–12, bounds, attribution "EMODnet Bathymetry" |
public, max-age=3600 |
GET /depth/v/<version>/<z>/<x>/<y>.mvt |
The depth tile, gzip; 204 where there's none; a deleted build answers from the current one | as for the base map |
GET /places.json |
TileJSON of the current island names build: tiles = https://tiles.sailever.com/places/v/<version>/{z}/{x}/{y}.mvt (version: the OpenStreetMap import's time, YYYYMMDDHHMMSS), zoom 5–15, bounds, attribution "© OpenStreetMap contributors" |
public, max-age=3600 |
GET /places/v/<version>/<z>/<x>/<y>.mvt |
The island names tile, gzip; 204 where there's none; a deleted build answers from the current one | as for the base map |
| Before the first build | 503 "No map yet" (also GET /depth.json and GET /places.json before their first build) |
— |
- Wear OS track map: paired Wear OS watches read tiles.json, depth.json and their tiles directly, with a style bundled in the app (track-view.md, ADR 018). Apps send no Origin, so CORS doesn't apply to them.
- CORS:
Access-Control-Allow-Originonly forhttps://beta.sailever.com,https://sailever.com,https://www.sailever.comandhttp://localhoston any port; other sites get no CORS header, so their scripts can't read the tiles. - The Worker answers from Cloudflare's cache first; a miss reads the version's manifest, picks the band file by the latitude of the tile's centre (each band file holds every tile touching its band, so the centre's band always has the tile), reads the tile with two to four range reads from R2 (directories stay in the Worker's memory) and stores the answer.
- Cache lookups that don't answer within 1.5 s are skipped and the tile is read from R2 (a stuck cache entry kept some tiles hanging, 2026-10-18). Cache keys carry a namespace (
?c2); raising it leaves every cached answer behind. Answers are stored from their bytes.
Monthly update¶
On the worker VM since 2026-10-23 (infra/basemap, ADR 011; before that .github/workflows/tiles.yml in GitHub Actions, which now only runs the Worker's tests). The jobs run in the worker container at the lowest priority on the VM, behind the Rails jobs, and share one lock with the depth build: never both at once.
| When | Command | What |
|---|---|---|
Every hour at :07 UTC, from the 2nd of a month while current.json is from an earlier month (or after python3 -m basemap start) |
basemap run |
Takes the newest Protomaps daily build, cuts the region with pmtiles extract (pmtiles 1.31.2, checksum checked) in five latitude bands (30–44, 44–48, 48–52, 52–60, 60–71.5° N; none above about 10 GB), one at a time: each band is extracted, uploaded as basemap-<YYYYMMDD>-<n>.pmtiles and deleted from the disk; a stopped build goes on with the next band. It refuses a total outside 30–70 GB, writes the manifest basemap-<YYYYMMDD>.json (the band files with their northern edge, the last one up to 90°), checks one Split harbour tile of the new version and only then writes current.json ({"version":"YYYYMMDD"}). Nothing happens when the newest build is already current; after a failure it waits 12 hours (BASEMAP_RETRY_HOURS). |
| Daily, 06:40 UTC | basemap cleanup |
Two days after a switch: deletes every band file and manifest of other versions than the current one (never those of a build under way), and every depth file except the current one. Skipped while the depth or basemap build runs. |
Every change to infra/tiles/ |
tiles.yml test |
The Worker's tests |
The version is part of every tile URL, so a new month never mixes with cached tiles of the old one: pages pick up the new version within an hour (tiles.json cache, plus up to 5 minutes in the Worker), and the old files stay two days for pages left open.
Depth¶
Depth bands and contour lines from the EMODnet Bathymetry Digital Terrain Model (latest release, DTM 2024; grid 1/16 arc-minute ≈ 115 m, mean depth per cell) and EMODnet's high-resolution survey patches where there are any, with OpenStreetMap's coastline as the 0 m line, on a grid of about 11 m near the coast. Always on. Why: ADR 008.
| Element | Content |
|---|---|
| Bands | Filled by depth, strong in the shallows and pale in deep water, the biggest step at 5 m: 0–2 m #5aafd9, 2–5 #7cc0e3, 5–10 #a3d4ec, 10–20 #bcdfef, 20–50 #c9e6f3, 50–100 #d3ebf6, 100–200 #d9eef7, 200–500 #def0f8, 500–1000 #e2f2f9, deeper #e5f3fa. The 0–2 m band reaches the coastline everywhere there is a measured depth nearby. Outlines drawn as smooth curves through the midpoints of their segments (corners at a tile's edge stay sharp, so tiles meet without gaps). |
| Drying | Only in tidal seas (not the Mediterranean, the Black Sea or the Baltic): sea the grid puts 0–6 m above 0 m, pale slate #b9cfd6 with fine dots #7f9ea8 |
| No depth data | Sea more than about 250 m from any measured depth (a bay the grid counts as land or leaves at 0.0, a harbour basin, beyond the layer's bounds): dark navy #1e3a5f with a fine diagonal hatch #46698f, drawn over the bands and lines, with a smooth outline. Never white, never land-coloured. |
| Inland water | Lakes, rivers, canals, ponds and lagoons that OpenStreetMap doesn't put inside the coastline (the Venice lagoon, s'Estany des Peix): no depth, plain water #d9ebf5 |
| Lines | 2, 5, 10, 20, 50, 100, 200, 500, 1000 m: #6f9fc2, 0.6 px, 80 %; the 5 m and 10 m lines #3a78a6, 1 px, 90 %. Up to zoom 7 only 200 m and deeper, up to zoom 10 from 50 m, from zoom 11 all. Smooth curves, round joins. |
| Labels | "10 m" along the line, #2d6a96 with a white halo, 10 px, from zoom 12, placed on a simplified copy of the line (layer depth_label) |
| Order | Each tile starts transparent: the theme's water (sea, lakes, rivers) in #d9ebf5 → bands, drying, lines, no-depth-data, drawn only on that water → the theme's land behind them → coastline → the rest of the theme (roads, buildings, labels). So the depth never shows on land and ends exactly at the base map's coastline. |
| Note | Under the trip map (only with SAILEVER_TILES_URL), 12 px, muted: "Depths are approximate and not for navigation. Near the shore they are estimated from the coastline; hatched dark blue water has no depth data." (logbook.trip.depth_note, all seven languages) |
| Older builds | depth.json without "schema": 2 (built before the worker VM): bands and lines as straight segments, no drying or no-depth-data areas, water without a band grey-green #cbd9c0 (#e5f3fa when depth.json has no deepest), labels on depth_line |
| Without depth | When depth.json doesn't answer, the map is drawn with the same land and sea (Base map), sea in one colour, no EMODnet credit |
How the depth is made (per area, infra/depth/depth/grid.py):
1. Sea: OpenStreetMap's water polygons (osmdata.openstreetmap.de, split, WGS84), the coastline the base map uses. Only water inside the coastline is sea.
2. EMODnet cells cleaned against the coastline: a cell that is mostly land → 0 m; below 0 m and mostly sea → kept; 0–6 m above 0 m, nearly all sea, in a tidal sea → kept (drying); everything else (EMODnet's 0.0 for "no sea data", sea and beach averaged, cells above 6 m on the sea) is filled between those by solving Laplace's equation.
3. Fine grid: cubic spline of the cleaned cells; high-resolution survey points blended in (weight fading over one survey cell at the patch's edge).
4. Coastline as 0 m: within 90 m of land the depth is solved again between the coastline (0 m) and the spline further out, then smoothed on the sea only (Gaussian, 15 m), the coast kept at 0 m. In tideless seas all sea is at least 0.05 m deep (no drying, no spline overshoot above 0 m).
5. No depth data: sea further than 250 m from a measured cell (an EMODnet cell kept in step 2, or a survey point) is marked, its outline smoothed.
6. Land fringe: land up to 20 m (at least 1.5 cells) from the sea takes the nearest sea cell's value, so the bands run under the base map's coastline and the website cuts them there; land further inland, lakes and rivers included, gets nothing.
Grids (infra/depth/depth/piece.py): blocks of 0.5° on a 0.0005° grid (≈ 55 m) for zoom 0–10 and the open sea; cells of 0.1° that hold both sea and land on a 0.0001° grid (≈ 11 m) for zoom 11–15. Each block and cell is computed with a margin (0.03°, 0.01°) and cut back to itself. Contours with GDAL: lines at the levels above, bands at the same levels plus 10000 m (the deep sea has a band too) and 0 to −100 m (drying), simplified by ≈ 2 m.
Tiles: all pieces → one depth-<YYYYMMDD>.pmtiles, zoom 0–15, layers depth_area (from_m, kind = band, drying, unknown), depth_line (depth, label), depth_label (label, zoom 12–15); simplified by a quarter pixel at every zoom. Leaflet zooms on to 18 by scaling.
Build on the worker VM (infra/worker, not in GitHub Actions):
- Every 15 minutes cron starts python3 -m depth run (nice 19, idle I/O, in a container with few CPU shares and at most 8 GB: the lowest priority on the VM, ADR 011). When a build is due or under way and no other heavy job (this one or the basemap) holds the shared lock, it carries the build straight through to the end; otherwise it returns at once. So a build continues by itself after a reboot or a deploy (the piece in progress is redone). After a failure it waits 6 hours (DEPTH_RETRY_HOURS) before the next try.
- A build: inputs (EMODnet's WCS ows.emodnet-bathymetry.eu/wcs, coverage emodnet__mean, in 2° × 2° pieces with a 0.05° margin, 8 at a time, kept per release; the water polygons, fetched again for each build; the list of high-resolution patches from EMODnet's WFS emodnet:hr_bathymetry_area, patches kept once fetched) → the 504 pieces of the region, two at a time → publish. More than 2 % missing DTM pieces stops the build.
- Publish: tiles, size check 0.3–12 GB, upload to R2 sailever-tiles, smoke test (a zoom 14 tile at Split, Golfo delle Saline, Telašćica, Vlychos, Den Helder and Marstrand through tiles.sailever.com/depth/v/<version>/…, each 200 with content), then depth-current.json {"version":"YYYYMMDD","release":"2024","deepest":10000,"schema":2}. A failed smoke test leaves the current build in place.
- The first build starts on its own once the worker runs (no state yet). The next one starts 30 days after the last one was published (DEPTH_CYCLE_DAYS), so the coastline follows OpenStreetMap about monthly; python3 -m depth start in the worker container makes one due now (and retries a failed one at once). A build takes about a day (estimated 2026-10-20: about 12,200 coastal cells of 0.1°, the archipelagos of Sweden, Finland and Norway the slowest; the pieces about 7 hours on 2 OCPU, writing the tiles about 10 hours).
- Release check at the start of each build: when EMODnet has archived the release in use (coverage emodnet__mean_<release> appears), the state gets a warning; set DEPTH_RELEASE in infra/worker/config/deploy.yml, update the credit and deploy the worker.
- State: state.json in the depth folder of the worker's data directory (/var/lib/sailever-worker on the VM), also uploaded as depth-status.json to the bucket (not served by the Worker). The basemap's daily cleanup deletes every depth file except the current one, two days after a switch.
- Licence: EMODnet CC BY 4.0, "EMODnet Bathymetry Consortium (2024): EMODnet Digital Bathymetry (DTM 2024)", with EMODnet's constraint "not for navigation". Because the depth is shaped by OpenStreetMap's coastline, the depth tiles are offered under the ODbL (credit "EMODnet Bathymetry (depth ODbL)" next to "© OpenStreetMap contributors").
Island names¶
The name of every island and islet in the sea that has one in OpenStreetMap, from our own OpenStreetMap places (the Protomaps theme draws none, and its tiles name only the bigger islands: around Brijuni two of eighteen).
| Element | Content |
|---|---|
| Which | Islands and islets (place=island, islet) with a name, whose outline is OpenStreetMap coastline: in the sea, not in a lake or river. Only in the imported countries (every coast of the map but the Black Sea's). |
| Text | name as OpenStreetMap has it, the local name (Veliki Brijun), never name:en |
| Style | Italic 600, #5b4a20 with a 2.5 px halo #fffaf0; 15 px from 100 km², 13 px from 1 km², 12 px from 0.01 km², 11 px below |
| From zoom | By area: ≥ 1000 km² 5, ≥ 100 km² 7, ≥ 10 km² 9, ≥ 1 km² 10, ≥ 0.1 km² 11, ≥ 0.01 km² 12, ≥ 1000 m² 13, smaller 14 (Brijuni: Veliki and Mali Brijun 10, Vanga 11, Gaz and Supin 12, Sveti Marko 13, Kabula 14) |
| Position | The centre of the largest circle inside the island (a point on its surface when that centre isn't on it) |
| Crowding | protomaps-leaflet's label placement: a name that would collide is left out. The islands from 0.1 km² are placed before the theme's own labels (towns, villages), larger islands first; the islets after them. |
| Without them | No places.json (before the first build, or the tile host can't answer): the map draws without island names |
- Build: the Rails jobs role writes the islands at the end of every
osm:refreshinto the worker's/data/places(ingestion.md);python3 -m places run(hourly on the worker VM,infra/places) turns a new export intoplaces-<version>.pmtileswith one layer per starting zoom (island_5…island_14; point,name,area_m2), uploads it, checks Brijuni at zoom 12 and the Balearics at zoom 7 through the tile host, and only then switches places-current.json. A failed build is tried again after 6 hours.basemap cleanupdeletes the old files two days after a switch. - Licence: OpenStreetMap, ODbL; the map credit already names "© OpenStreetMap contributors".
Data, backups, costs¶
- The bucket holds nothing but rebuildable data, so it is in no backup, on purpose; it is outside OCI and outside Active Storage.
- Costs: R2 storage beyond the free 10 GB, about US$0.55 a month for 46 GB (two versions for two days a month), plus a few cents for the depth layer (2–3 GB estimated). Worker requests (100,000 a day free), R2 reads (10 million a month free), and Cloudflare's cache are free at beta traffic; the worker VM is Always Free (its outbound traffic, about 50 GB a month, is far inside OCI's free 10 TB).