Kemp.rest / Examples / Code / OpenStreetMap
KEMP.REST / EXAMPLES / CODE
OpenStreetMap marinas and anchorages
Written by Kevin Kemp in PHP for Offing: Following Seas 1.5.1. Overpass is contacted only while an administrator runs the import.
View the codeMarinas and anchorages come from OpenStreetMap through the Overpass API, then sit in a local MySQL table. Page views read that table. The same import tile also asks NOAA for charted anchorages; that call is on the NOAA page.
Type of code
A WordPress admin import, one HTTP request per 3° tile, driven from the browser so a single request never has to finish the whole coast.
Technology
PHP, the WordPress HTTP API, Overpass QL, JSON, and MySQL through $wpdb. The endpoint is https://overpass-api.de/api/interpreter, and a filter can point it at another instance. The user agent carries the plugin version and the site address. Rows store the OSM type and id, a centre point, the kind, and a short list of tags. The licence is the Open Database License. The optional street layer on the radar uses OpenStreetMap raster tiles in the browser, tile.openstreetmap.org/{z}/{x}/{y}.png, drawn by the same canvas tile layer as the NOAA chart.
What it does
- Each tile asks for nodes, ways, and relations tagged
leisure=marina,seamark:harbour:category=marina,seamark:type=anchorage, orseamark:type=anchor_berth, without center tags. - Ways and relations use the centre Overpass returns. A seamark anchorage or anchor berth is stored as an anchorage. Everything else in the query is a marina.
- Only a fixed tag list is kept: name, phone, VHF channel, fuel, berths, depth, and the address. Other tags are dropped.
- HTTP 429, 503, and 504 come back as retry. A remark that contains “error” is a retry too. When every tile has answered, places inside the area that OpenStreetMap no longer has are removed.
Problem it solved
The radar needed marinas and anchorages a family could recognise, with phone and VHF where OpenStreetMap has them, without calling Overpass on every page view.
Code
Back to summary · Following Seas 1.5.1 · OpenStreetMap
// —— includes/class-pcaisr-marinas.php — query, tiles, and the tags that are kept ——
<?php
/**
* Marinas and anchorages from OpenStreetMap, imported through the Overpass API into a local table.
* marinas: leisure=marina or seamark:harbour:category=marina
* anchorages: seamark:type=anchorage or seamark:type=anchor_berth
* The import runs only when an administrator clicks "Import / refresh places"; page views never contact Overpass.
* Data licence: ODbL. Credit "© OpenStreetMap contributors" wherever it is shown.
*
* @package FollowingSeas
*/
defined('ABSPATH') || exit;
final class PCAISR_Marinas
{
const DB_VER = '5';
const TILE_DEG = 3.0;
const OVERPASS = 'https://overpass-api.de/api/interpreter';
/* NOAA Office of Coast Survey, ENC feature ACHARE (charted anchorage areas). */
const NOAA_ANCHORAGES = 'https://gis.charttools.noaa.gov/arcgis/rest/services/NavigationChartData/Anchorage_Areas/MapServer/0/query';
/* OSM tags kept per place (everything else is dropped to keep the table small). */
const KEEP = [
'name', 'seamark:name', 'leisure', 'seamark:type', 'seamark:harbour:category',
'seamark:anchorage:category', 'seamark:anchorage:restriction', 'seamark:anchorage:status',
'seamark:anchorage:depth_range_minimum', 'seamark:anchorage:depth_range_maximum',
'seamark:anchor_berth:category', 'seamark:anchor_berth:radius', 'anchor', 'note', 'operator', 'description', 'opening_hours',
'addr:housenumber', 'addr:street', 'addr:city', 'addr:state', 'addr:postcode',
'phone', 'contact:phone', 'website', 'contact:website', 'url', 'email', 'contact:email',
'vhf', 'vhf_channel', 'seamark:radio_station:channel',
'capacity', 'berths', 'mooring', 'maxlength', 'depth', 'maxdepth', 'maxdraft', 'draught',
'fuel', 'fuel:diesel', 'fuel:gasoline', 'fuel:octane_89', 'fuel:octane_91', 'fuel:octane_95',
'power_supply', 'electricity', 'drinking_water', 'sanitary_dump_station', 'toilets', 'shower',
'boat_rental', 'slipway', 'fee',
];
public static function overpass_url(): string
{
return (string)apply_filters('pcaisr_overpass_url', self::OVERPASS);
}
public static function noaa_url(): string
{
return (string)apply_filters('pcaisr_noaa_anchorages_url', self::NOAA_ANCHORAGES);
}
/** Import area from settings: [south, west, north, east] */
public static function bbox(): array
{
$s = PCAISR::settings();
return [(float)$s['marina_s'], (float)$s['marina_w'], (float)$s['marina_n'], (float)$s['marina_e']];
}
public static function tiles(array $b): array
{
[$s, $w, $n, $e] = $b;
$out = [];
for ($la = $s; $la < $n - 1e-9; $la += self::TILE_DEG) {
for ($lo = $w; $lo < $e - 1e-9; $lo += self::TILE_DEG) {
$out[] = [round($la, 4), round($lo, 4), round(min($n, $la + self::TILE_DEG), 4), round(min($e, $lo + self::TILE_DEG), 4)];
}
}
return $out;
}
public static function meta(): array
{
$m = get_option('pcaisr_marina_meta', []);
return is_array($m) ? $m : [];
}
/** marina or anchorage, decided by the OSM tags the element carries. */
private static function kind_of(array $tags): string
{
$seamark = $tags['seamark:type'] ?? '';
if ($seamark === 'anchorage' || $seamark === 'anchor_berth') {
return 'anchorage';
}
return 'marina';
}
// —— includes/class-pcaisr-marinas.php — one Overpass tile ——
/** Fetch one tile from Overpass and upsert its marinas. Returns ok / retry / error. */
public static function import_tile(int $runId, int $i): array
{
global $wpdb;
$run = get_option('pcaisr_marina_run');
if (!is_array($run) || (int)$run['run'] !== $runId || !isset($run['tiles'][$i])) {
return [
'ok' => false,
'error' => __('This import was replaced by a newer one. Start it again.', 'following-seas'),
];
}
[$s, $w, $n, $e] = $run['tiles'][$i];
$bb = sprintf('(%F,%F,%F,%F)', $s, $w, $n, $e);
$q = '[out:json][timeout:120];('
. 'nwr["leisure"="marina"]' . $bb . ';'
. 'nwr["seamark:harbour:category"="marina"]' . $bb . ';'
. 'nwr["seamark:type"="anchorage"]' . $bb . ';'
. 'nwr["seamark:type"="anchor_berth"]' . $bb . ';'
. ');out center tags;';
$res = wp_remote_post(self::overpass_url(), [
'timeout' => 120,
'user-agent' => 'Offing-Following-Seas/' . PCAISR_VERSION . ' (WordPress; ' . home_url('/') . ')',
'body' => ['data' => $q],
]);
if (is_wp_error($res)) {
return [
'ok' => false,
'retry' => true,
/* translators: %s: error message from the HTTP request */
'error' => sprintf(__('Overpass request failed: %s', 'following-seas'), $res->get_error_message()),
];
}
$code = (int)wp_remote_retrieve_response_code($res);
if ($code === 429 || $code === 504 || $code === 503) {
return [
'ok' => false,
'retry' => true,
/* translators: %d: HTTP status code */
'error' => sprintf(__('Overpass is busy (HTTP %d).', 'following-seas'), $code),
];
}
$j = json_decode(wp_remote_retrieve_body($res), true);
if ($code !== 200 || !is_array($j) || !isset($j['elements'])) {
return [
'ok' => false,
'retry' => true,
/* translators: %d: HTTP status code */
'error' => sprintf(__('Unexpected Overpass response (HTTP %d).', 'following-seas'), $code),
];
}
if (!empty($j['remark']) && stripos($j['remark'], 'error') !== false) {
return [
'ok' => false,
'retry' => true,
/* translators: %s: message returned by the Overpass API */
'error' => sprintf(__('Overpass: %s', 'following-seas'), mb_substr($j['remark'], 0, 200)),
];
}
$t = self::table();
$now = current_time('mysql', true);
$saved = 0;
foreach ($j['elements'] as $el) {
$type = substr((string)($el['type'] ?? ''), 0, 1);
if (!in_array($type, ['n', 'w', 'r'], true) || empty($el['id'])) continue;
$lat = $el['lat'] ?? ($el['center']['lat'] ?? null);
$lon = $el['lon'] ?? ($el['center']['lon'] ?? null);
if (!is_numeric($lat) || !is_numeric($lon)) continue;
$tags = [];
foreach (self::KEEP as $k) {
if (isset($el['tags'][$k]) && is_string($el['tags'][$k]) && $el['tags'][$k] !== '') {
$tags[$k] = mb_substr(wp_strip_all_tags($el['tags'][$k]), 0, 300);
}
}
$kind = self::kind_of($tags);
$name = $tags['name'] ?? ($tags['seamark:name'] ?? '');
if ($name !== '' && !isset($tags['name'])) {
$tags['name'] = $name;
}
self::upsert($type, (int) $el['id'], $name, (float) $lat, (float) $lon, $kind, 'osm', $tags, null, $runId, $now);
++$saved;
}
// Charted anchorage areas for the same tile. A NOAA failure does not fail the tile,
// but it does stop the prune, so nothing charted is lost when the service is down.
$noaa = self::noaa_tile($runId, $run['tiles'][$i]);
if ($noaa['error'] !== '') {
$run['noaa_failed'] = true;
$run['noaa_error'] = $noaa['error'];
}
$saved += (int) $noaa['saved'];
$run['saved'] += $saved;
$run['saved_noaa'] = (int) ($run['saved_noaa'] ?? 0) + (int) $noaa['saved'];
update_option('pcaisr_marina_run', $run, false);
return [
'ok' => true,
'saved' => $saved,
'noaa' => (int) $noaa['saved'],
'warning' => $noaa['error'],
];
}
// —— assets/radar.js — optional street tiles ——
/* Street map: OpenStreetMap tiles by default; a site can point CFG.street.url at another {z}/{x}/{y} source, or set CFG.street = false (or data-street="0") to remove it. */
const streetOff = root.dataset.street === '0' || CFG.street === false;
const STR = streetOff ? null : Object.assign({ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', minz: 2, maxz: 19 }, CFG.street || {});
const STL = STR ? TileLayer(STR) : null;