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 code

Marinas 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

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;