Kemp.rest / Examples / Code / NOAA

KEMP.REST / EXAMPLES / CODE

NOAA chart and charted anchorages

Written by Kevin Kemp in PHP and JavaScript for Offing: Following Seas 1.5.1. The visitor’s browser requests the chart tiles. The anchorage areas are fetched by the server during import.

View the code

Two NOAA services sit under the radar. The Chart Display Service supplies the nautical chart, and the ENC anchorage layer supplies the charted anchorage areas. The chart is the only outside request a visitor’s browser makes, and the shortcode can leave it off.

Type of code

A tile URL handed to the canvas, and a server import that pages NOAA’s anchorage query and stores each area as a ring. The drawing lives in radar.js.

Technology

PHP, the WordPress HTTP API, JSON, JavaScript, and an HTML canvas. Chart tiles are MarineChart_Services/NOAACharts in Web Mercator. NOAA’s zoom 0 is web zoom 2, so the plugin sends an offset of 2 and asks for zooms 3 through 18. Anchorage areas are the ACHARE feature, WGS84, 500 records a page, with maxAllowableOffset 0.0005. A CSS filter, invert(1) hue-rotate(180deg), turns the day chart into the night palette when the browser allows ctx.filter.

What it does

Problem it solved

People following a boat needed a real nautical chart and the anchorage areas printed on it, on the same screen as the live AIS target, with a way to turn the chart off.

Code

Back to summary · Following Seas 1.5.1 · NOAA

// —— includes/class-pcaisr-shortcode.php — chart tile source ——

        // NOAA Chart Display Service (Office of Coast Survey), cached Web Mercator tiles. Its level 0 is web zoom 2.
        $chart = apply_filters(
            'pcaisr_chart_tiles',
            [
                'url'  => 'https://gis.charttools.noaa.gov/arcgis/rest/services/MarineChart_Services/NOAACharts/MapServer/tile/{l}/{y}/{x}',
                'zoff' => 2,
                'minz' => 3,
                'maxz' => 18,
            ]
        );

// —— includes/class-pcaisr-marinas.php — charted anchorage areas ——

    /**
     * Charted anchorage areas for one tile, from NOAA's ENC data (feature ACHARE).
     * Polygons are simplified by the service and again here, and stored as a ring of
     * [lat, lon] pairs with the area's centre in lat/lon.
     */
    private static function noaa_tile(int $runId, array $tile): array
    {
        [$s, $w, $n, $e] = $tile;
        $now    = current_time('mysql', true);
        $saved  = 0;
        $offset = 0;

        for ($page = 0; $page < 6; $page++) {
            $url = add_query_arg(
                [
                    'where'              => '1=1',
                    'geometry'           => implode(',', [$w, $s, $e, $n]),
                    'geometryType'       => 'esriGeometryEnvelope',
                    'spatialRel'         => 'esriSpatialRelIntersects',
                    'inSR'               => '4326',
                    'outSR'              => '4326',
                    'outFields'          => 'OBJECTID,OBJNAM,INFORM,OBJL',
                    'returnGeometry'     => 'true',
                    'geometryPrecision'  => '5',
                    'maxAllowableOffset' => '0.0005',
                    'resultRecordCount'  => '500',
                    'resultOffset'       => (string) $offset,
                    'f'                  => 'json',
                ],
                self::noaa_url()
            );
            $res = wp_remote_get(
                $url,
                [
                    'timeout'    => 60,
                    'user-agent' => 'Offing-Following-Seas/' . PCAISR_VERSION . ' (WordPress; ' . home_url('/') . ')',
                ]
            );
            if (is_wp_error($res)) {
                /* translators: %s: error message from the HTTP request */
                return ['saved' => $saved, 'error' => sprintf(__('NOAA request failed: %s', 'following-seas'), $res->get_error_message())];
            }
            $code = (int) wp_remote_retrieve_response_code($res);
            if ($code !== 200) {
                /* translators: %d: HTTP status code */
                return ['saved' => $saved, 'error' => sprintf(__('NOAA returned HTTP %d.', 'following-seas'), $code)];
            }
            $j = json_decode(wp_remote_retrieve_body($res), true);
            if (!is_array($j) || isset($j['error'])) {
                $msg = isset($j['error']['message']) ? $j['error']['message'] : __('unreadable response', 'following-seas');
                /* translators: %s: message from the NOAA service */
                return ['saved' => $saved, 'error' => sprintf(__('NOAA: %s', 'following-seas'), mb_substr((string) $msg, 0, 200))];
            }
            $features = isset($j['features']) && is_array($j['features']) ? $j['features'] : [];
            foreach ($features as $f) {
                $attr = isset($f['attributes']) && is_array($f['attributes']) ? $f['attributes'] : [];
                $oid  = isset($attr['OBJECTID']) ? (int) $attr['OBJECTID'] : 0;
                $ring = self::outer_ring($f['geometry'] ?? []);
                if (!$oid || !$ring) {
                    continue;
                }
                $box  = self::ring_box($ring);
                $name = trim((string) ($attr['OBJNAM'] ?? ''));
                if ($name === '') {
                    $name = __('Charted anchorage', 'following-seas');
                }
                $tags = ['noaa' => '1'];
                if (!empty($attr['INFORM'])) {
                    $tags['inform'] = mb_substr(wp_strip_all_tags((string) $attr['INFORM']), 0, 400);
                }
                if (!empty($attr['OBJL'])) {
                    $tags['objl'] = mb_substr(wp_strip_all_tags((string) $attr['OBJL']), 0, 60);
                }
                self::upsert('x', $oid, $name, $box['lat'], $box['lon'], 'anchorage', 'noaa', $tags, $ring, $runId, $now);
                ++$saved;
            }
            if (count($features) < 500 || empty($j['exceededTransferLimit'])) {
                break;
            }
            $offset += 500;
        }
        return ['saved' => $saved, 'error' => ''];
    }

    /** Largest ring of an Esri polygon, as [[lat, lon], ...], capped at 120 points. */
    private static function outer_ring(array $geom): ?array
    {
        $rings = isset($geom['rings']) && is_array($geom['rings']) ? $geom['rings'] : [];
        $best  = null;
        foreach ($rings as $r) {
            if (is_array($r) && count($r) >= 4 && ($best === null || count($r) > count($best))) {
                $best = $r;
            }
        }
        if (!$best) {
            return null;
        }
        $step = (int) max(1, ceil(count($best) / 120));
        $out  = [];
        foreach ($best as $i => $pt) {
            if ($i % $step !== 0 || !isset($pt[0], $pt[1])) {
                continue;
            }
            $out[] = [round((float) $pt[1], 5), round((float) $pt[0], 5)];   // [lat, lon]
        }
        return count($out) >= 3 ? $out : null;
    }

    private static function ring_box(array $ring): array
    {
        $las = array_column($ring, 0);
        $los = array_column($ring, 1);
        return [
            'lat' => ( min($las) + max($las) ) / 2,
            'lon' => ( min($los) + max($los) ) / 2,
        ];
    }

// —— assets/radar.js — night chart on the canvas ——

  /* ---------- NOAA chart background (Web Mercator tiles, drawn once per view into an offscreen canvas) ---------- */
  const chartOff = root.dataset.chart === '0';
  const CH = (CFG.chart && CFG.chart.url && !chartOff) ? CFG.chart : null;
  const NIGHT = 'invert(1) hue-rotate(180deg) brightness(0.8) contrast(0.95)';
  const canFilter = (() => { try { const c = document.createElement('canvas').getContext('2d'); c.filter = NIGHT; return c.filter === NIGHT; } catch (e) { return false; } })();
  const lon2x = (lon, n) => (lon + 180) / 360 * n;
  const lat2y = (lat, n) => { const r = lat * Math.PI / 180; return (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * n; };
  const x2lon = (x, n) => x / n * 360 - 180;
  const y2lat = (y, n) => Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;

  /*
   * A tile layer: Web Mercator tiles drawn into its own offscreen canvas and redrawn only when the view changes.
   * The NOAA chart and the street map are two of these.
   */
  function TileLayer(src){
    const tiles = new Map(), L = { src, loads: 0, cv: null, key: '' };
    function tile(z, x, y){
      const key = z + '/' + x + '/' + y;
      let t = tiles.get(key);
      if (t && (t.ok || t.loading || (t.failAt && Date.now() - t.failAt < 60000))) return t;
      if (!t) { t = {}; tiles.set(key, t); if (tiles.size > 400) tiles.delete(tiles.keys().next().value); }
      t.loading = true;
      const img = new Image();
      img.decoding = 'async';
      img.onload = () => { t.ok = true; t.loading = false; t.img = img; L.loads++; };
      img.onerror = () => { t.loading = false; t.failAt = Date.now(); };
      img.src = src.url.replace('{l}', z - (src.zoff || 0)).replace('{z}', z).replace('{x}', x).replace('{y}', y);
      return t;
    }
    L.draw = (g, t) => {
      const rng = RANGES[S.rangeIdx];
      const key = [t.lat.toFixed(5), t.lon.toFixed(5), g.pan.x.toFixed(4), g.pan.y.toFixed(4), rng, g.up.toFixed(1), W, DPR, L.loads].join('|');
      if (L.cv && key === L.key) return L.cv;
      L.key = key;
      // Tiles are fetched around the centre of the view, which is the vessel unless the visitor has panned.
      const boatCos = Math.max(0.05, Math.cos(t.lat * Math.PI / 180));
      const cLat = t.lat + g.pan.y / 60, cLon = t.lon + g.pan.x / (60 * boatCos);
      const cosl = Math.max(0.05, Math.cos(cLat * Math.PI / 180));
      let z = Math.round(Math.log2(156543.034 * cosl * DPR * g.scale / 1852));
      z = Math.max(src.minz, Math.min(src.maxz, z));
      const dLat = rng / 60 * 1.05, dLon = rng / (60 * cosl) * 1.05;
      let n, x0, x1, y0, y1;
      for (;;) {
        n = 2 ** z;
        x0 = Math.floor(lon2x(cLon - dLon, n)); x1 = Math.floor(lon2x(cLon + dLon, n));
        y0 = Math.floor(lat2y(cLat + dLat, n)); y1 = Math.floor(lat2y(cLat - dLat, n));
        if ((x1 - x0 + 1) * (y1 - y0 + 1) <= 64 || z <= src.minz) break;
        z--;
      }
      if (!L.cv) L.cv = document.createElement('canvas');
      if (L.cv.width !== cv.width || L.cv.height !== cv.height) { L.cv.width = cv.width; L.cv.height = cv.height; }
      const c = L.cv.getContext('2d');
      c.setTransform(1, 0, 0, 1, 0, 0); c.globalCompositeOperation = 'source-over'; c.filter = 'none';
      c.clearRect(0, 0, L.cv.width, L.cv.height);
      if (canFilter) c.filter = NIGHT;
      const pt = (x, y) => { const p = rel(t, { lat: y2lat(y, n), lon: x2lon(x, n) }); return toScreen(g, p.x, p.y); };
      let drawn = 0;
      for (let ty = Math.max(0, y0); ty <= Math.min(n - 1, y1); ty++) {
        for (let tx = x0; tx <= x1; tx++) {
          const tl = tile(z, ((tx % n) + n) % n, ty);
          if (!tl.ok) continue;
          const a = pt(tx, ty), b = pt(tx + 1, ty), d = pt(tx, ty + 1);
          c.setTransform(DPR * (b[0] - a[0]) / 256, DPR * (b[1] - a[1]) / 256, DPR * (d[0] - a[0]) / 256, DPR * (d[1] - a[1]) / 256, DPR * a[0], DPR * a[1]);
          c.drawImage(tl.img, -0.3, -0.3, 256.6, 256.6);
          drawn++;
        }
      }
      c.filter = 'none'; c.setTransform(1, 0, 0, 1, 0, 0);
      return drawn ? L.cv : null;
    };
    return L;
  }
  const CHL = CH ? TileLayer(CH) : null;