Skip to content

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.

  1. 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).
  2. 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).
  3. 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).
  4. 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).
  5. 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-Origin only for https://beta.sailever.com, https://sailever.com, https://www.sailever.com and http://localhost on 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:refresh into the worker's /data/places (ingestion.md); python3 -m places run (hourly on the worker VM, infra/places) turns a new export into places-<version>.pmtiles with 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 cleanup deletes 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).