PageSourceSearch

https://chartnav.ai/ports

html chartnav.ai collected 2026-10-05 14:15:50 UTC 14,839 bytes, 251 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4<script>document.documentElement.setAttribute('data-theme',localStorage.getItem('cn-theme')||'light')</script>
4
5  <meta charset="UTF-8">
6  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7  <title>ChartNav — Ports & Anchorages</title>
8  <meta name="description" content="Browse ports and anchorages. See live vessel counts, search by name, sorted by proximity.">
9  <link rel="icon" type="image/svg+xml" href="/icon.svg">
10  <link rel="canonical" href="https://chartnav.ai/ports">
11  <link rel="preconnect" href="https://fonts.googleapis.com">
12  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
13  <link rel="stylesheet" href="/shared.css">
14</head>
15<body>
16
17  <!-- Sidebar Navigation -->
18  <nav class="cn-nav-bar">
19  <a href="/" class="cn-nav-brand">
20    <svg viewBox="0 0 512 512" fill="none"><circle cx="256" cy="256" r="200" stroke="#7ec8e3" stroke-width="16" fill="none"/><path d="M128 220Q192 160 256 220Q320 280 384 220" stroke="#7ec8e3" stroke-width="14" fill="none"/><path d="M128 300Q192 240 256 300Q320 360 384 300" stroke="#3a7cbd" stroke-width="14" fill="none"/></svg>
21    <span>ChartNav</span>
22  </a>
23  <div class="cn-nav-links">
24    <a href="/" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg> Chart</a>
25    <a href="/vessels" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 20l2-2 4 2 4-2 4 2 4-2 2 2"/><path d="M4 18V6l4-2 4 2 4-2 4 2v12"/></svg> Vessels</a>
26    <div class="cn-nav-sub" id="recent-vessels"></div>
27    <a href="/ports" class="cn-nav-link active"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="22"/><path d="M5 19h14"/><path d="M5 19a7 7 0 0114 0"/></svg> Ports</a>
28    <div class="cn-nav-sub" id="recent-ports"></div>
29    <a href="/news" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z"/></svg> News</a>
30    <a href="/storms" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3a9 9 0 019 9c0 2.6-1.9 4.2-4.2 4.2M12 21a9 9 0 01-9-9c0-2.6 1.9-4.2 4.2-4.2"/><circle cx="12" cy="12" r="2.6"/></svg> Storms</a>
31    <a href="/forecasts" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 8h11a3 3 0 10-3-3"/><path d="M3 12h15a3 3 0 11-3 3"/><path d="M3 19c1.5-1.3 3-1.3 4.5 0s3 1.3 4.5 0 3-1.3 4.5 0 3 1.3 4.5 0"/></svg> Forecasts</a>
32    <a href="/fishing" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12c3-4.5 7-6.5 11-6.5 3.5 0 6 1.5 9 6.5-3 5-5.5 6.5-9 6.5-4 0-8-2-11-6.5z"/><circle cx="16.5" cy="10.5" r="1" fill="currentColor"/><path d="M2 12l4-3M2 12l4 3"/></svg> Fishing</a>
33    <div class="cn-nav-group" id="cn-nav-boat">My boat</div>
34    <a href="/profile" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5"/></svg> Profile</a>
35    <a href="/marks" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s-7-5.5-7-12a7 7 0 0114 0c0 6.5-7 12-7 12z"/><circle cx="12" cy="10" r="2.5"/></svg> Marks</a>
36    <a href="/routes" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="19" r="2"/><circle cx="18" cy="5" r="2"/><path d="M8 19h6a4 4 0 0 0 0-8h-4a4 4 0 0 1 0-8h6"/></svg> Routes</a>
37    <a href="/tracks" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l5-6 4 4 8-10"/><circle cx="3" cy="18" r="1.5" fill="currentColor"/><circle cx="20" cy="6" r="1.5" fill="currentColor"/></svg> Tracks</a>
38    <a href="/buddies" class="cn-nav-link"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3.5"/><path d="M2 20c0-3.5 3-5.5 7-5.5s7 2 7 5.5"/><path d="M16.5 4.9a3.5 3.5 0 010 6.2"/><path d="M18.5 14.9c2.2.8 3.5 2.5 3.5 5.1"/></svg> Buddies</a>
39  </div>
40  <div class="cn-nav-footer">
41    <button class="cn-theme-btn" id="theme-btn" title="Toggle light/dark"
42  onclick="if(window.CN&&CN.toggleTheme){CN.toggleTheme()}else{var d=document.documentElement,t=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',t);try{localStorage.setItem('cn-theme',t)}catch(e){}}">
43  <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg></button>
44    <div class="cn-nav-legal"><a href="/for.html">Who it's for</a> <a href="/privacy.html">Privacy</a> <a href="/terms.html">Terms</a></div>
45  </div>
46</nav>
47<script>(function(){try{var p=JSON.parse(localStorage.getItem('chartnav-boat-profile')||'{}');var n=String(p.name||'').trim();if(n){var el=document.getElementById('cn-nav-boat');if(el)el.textContent=n;}}catch(e){}})();</script>
47
48
49  <!-- Main Content -->
50  <div class="cn-page">
51    <div class="cn-topbar">
52      <div class="cn-search">
53        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
54        <input type="text" id="search-input" placeholder="Search ports and anchorages...">
55      </div>
56      <div class="cn-topbar-right">
57        <span class="cn-results" id="results-count"></span>
58      </div>
59    </div>
60
61    <div class="cn-loading" id="loading-bar" style="display:none"></div>
62
63    <main class="cn-table-wrap">
64      <table class="cn-table">
65        <thead>
66          <tr>
67            <th style="width:32px"></th>
68            <th data-sort="name">Port Name <span class="sort-arrow"></span></th>
69            <th>Position</th>
70            <th data-sort="vesselCount">Vessels <span class="sort-arrow"></span></th>
71            <th class="hide-mobile" data-sort="_dist">Distance <span class="sort-arrow"></span></th>
72            <th style="width:60px"></th>
73          </tr>
74        </thead>
75        <tbody id="port-tbody"></tbody>
76      </table>
77      <div class="cn-empty" id="empty-state" style="display:none">
78        <p id="empty-msg">No ports found.</p>
79      </div>
80    </main>
81
82    <div class="cn-pagination" id="pagination"></div>
83  </div>
84
85  
85<script src="/shared.js"></script>
85
86  
86<script>
87  (function(){
88    'use strict';
89
90    var PAGE_SIZE = 50, currentPage = 0, currentQuery = '', totalPorts = 0;
91    var currentSort = 'vesselCount', currentOrder = 'desc';
92    var allPorts = null;
93
94    var tbody = document.getElementById('port-tbody');
95    var emptyState = document.getElementById('empty-state');
96    var emptyMsg = document.getElementById('empty-msg');
97    var loadingBar = document.getElementById('loading-bar');
98    var resultsCount = document.getElementById('results-count');
99    var paginationEl = document.getElementById('pagination');
100    var searchInput = document.getElementById('search-input');
101    var searchTimeout = null;
102
103    searchInput.addEventListener('input', function() {
104      clearTimeout(searchTimeout);
105      searchTimeout = setTimeout(function() {
106        currentQuery = searchInput.value.trim();
107        currentPage = 0;
108        renderFromCache();
109      }, 200);
110    });
111
112    loadAllPorts();
113
114    function loadAllPorts() {
115      loadingBar.style.display = 'block';
116      fetch('/api/ports?limit=9999').then(function(r) { return r.json(); }).then(function(data) {
117        allPorts = data.ports || [];
118
119        // Compute distance if we have a cached user position from the map
120        var loc = CN.getUserLocation();
121        if (loc.lat) {
122          for (var i = 0; i < allPorts.length; i++) {
123            allPorts[i]._dist = haversine(loc.lat, loc.lon, allPorts[i].lat, allPorts[i].lon);
124          }
125          // Sort nearby first by default
126          currentSort = '_dist'; currentOrder = 'asc';
127        }
128
129        renderFromCache();
130        loadingBar.style.display = 'none';
131      }).catch(function() {
132        loadingBar.style.display = 'none';
133        emptyMsg.textContent = 'Failed to load ports.';
134        emptyState.style.display = 'block';
135      });
136    }
137
138    function renderFromCache() {
139      if (!allPorts) return;
140      var filtered = allPorts;
141
142      if (currentQuery) {
143        var q = currentQuery.toLowerCase();
144        filtered = allPorts.filter(function(p) { return p.name.toLowerCase().indexOf(q) !== -1; });
145      }
146
147      filtered.sort(function(a, b) {
148        var va, vb;
149        if (currentSort === 'name') { va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase(); }
150        else if (currentSort === 'vesselCount') { va = a.vesselCount || 0; vb = b.vesselCount || 0; }
151        else if (currentSort === '_dist') { va = a._dist != null ? a._dist : 999999; vb = b._dist != null ? b._dist : 999999; }
152        else { va = a[currentSort] || 0; vb = b[currentSort] || 0; }
153        if (va < vb) return currentOrder === 'asc' ? -1 : 1;
154        if (va > vb) return currentOrder === 'asc' ? 1 : -1;
155        return 0;
156      });
157
158      totalPorts = filtered.length;
159      var page = filtered.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE);
160      renderTable(page);
161      CN.renderPagination(paginationEl, currentPage, totalPorts, PAGE_SIZE);
162      resultsCount.textContent = totalPorts.toLocaleString() + ' port' + (totalPorts !== 1 ? 's' : '') + (currentQuery ? ' matching "' + currentQuery + '"' : '');
163      refreshSortIndicators();
164    }
165
166    function renderTable(ports) {
167      if (!ports.length) {
168        tbody.innerHTML = '';
169        emptyMsg.textContent = 'No ports found.';
170        emptyState.style.display = 'block';
171        return;
172      }
173      emptyState.style.display = 'none';
174
175      var html = '';
176      for (var i = 0; i < ports.length; i++) {
177        var p = ports[i];
178        var lat = Math.abs(p.lat).toFixed(2) + '\u00b0' + (p.lat >= 0 ? 'N' : 'S');
179        var lon = Math.abs(p.lon).toFixed(2) + '\u00b0' + (p.lon >= 0 ? 'E' : 'W');
180        var distStr = p._dist != null ? p._dist.toFixed(0) + ' nm' : '\u2014';
181        var countStyle = p.vesselCount > 0 ? 'font-weight:700;color:var(--accent-light)' : 'color:var(--text-muted)';
182
183        html += '<tr data-slug="' + CN.esc(p.slug) + '">';
184        html += '<td style="text-align:center"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="var(--text-muted)" stroke-width="2"><circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="22"/><path d="M5 19h14"/><path d="M5 19a7 7 0 0114 0"/></svg></td>';
185        html += '<td style="font-weight:600">' + CN.esc(p.name) + '</td>';
186        html += '<td style="font-size:12px;color:var(--text-muted)">' + lat + ' ' + lon + '</td>';
187        html += '<td style="' + countStyle + '">' + p.vesselCount + '</td>';
188        html += '<td class="hide-mobile" style="font-size:12px;color:var(--text-muted)">' + distStr + '</td>';
189        html += '<td style="white-space:nowrap"><a href="/port/' + CN.esc(p.slug) + '" onclick="event.stopPropagation()" title="Port details" style="color:var(--accent-light);display:inline-flex;vertical-align:middle"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>';
190        html += ' <a href="/?lat=' + p.lat + '&lon=' + p.lon + '&zoom=13" onclick="event.stopPropagation()" title="Show on chart" style="color:var(--text-muted);display:inline-flex;vertical-align:middle"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg></a></td>';
191        html += '</tr>';
192      }
193      tbody.innerHTML = html;
194    }
195
196    // Sort headers
197    function refreshSortIndicators() {
198      document.querySelectorAll('thead th[data-sort]').forEach(function(h) {
199        var active = h.getAttribute('data-sort') === currentSort;
200        var arrow = h.querySelector('.sort-arrow');
201        if (arrow) arrow.textContent = active ? (currentOrder === 'asc' ? ' \u25b2' : ' \u25bc') : '';
202        h.setAttribute('aria-sort', active ? (currentOrder === 'asc' ? 'ascending' : 'descending') : 'none');
203      });
204    }
205
206    function sortBy(col) {
207      if (currentSort === col) currentOrder = currentOrder === 'asc' ? 'desc' : 'asc';
208      else { currentSort = col; currentOrder = col === 'name' || col === '_dist' ? 'asc' : 'desc'; }
209      currentPage = 0;
210      renderFromCache();
211    }
212
213    document.querySelectorAll('thead th[data-sort]').forEach(function(th) {
214      th.tabIndex = 0;
215      th.addEventListener('click', function() { sortBy(th.getAttribute('data-sort')); });
216      th.addEventListener('keydown', function(e) {
217        if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
218          e.preventDefault();
219          sortBy(th.getAttribute('data-sort'));
220        }
221      });
222    });
223
224    // Row click
225    tbody.addEventListener('click', function(e) {
226      var row = e.target.closest('tr[data-slug]');
227      if (row) window.location.href = '/port/' + row.getAttribute('data-slug');
228    });
229
230    // Pagination
231    paginationEl.addEventListener('click', function(e) {
232      var btn = e.target.closest('.cn-page-btn');
233      if (!btn || btn.disabled) return;
234      var pg = btn.getAttribute('data-page');
235      if (pg === 'prev') currentPage = Math.max(0, currentPage - 1);
236      else if (pg === 'next') currentPage++;
237      else currentPage = parseInt(pg);
238      renderFromCache();
239      window.scrollTo({ top: 0, behavior: 'smooth' });
240    });
241
242    function haversine(lat1, lon1, lat2, lon2) {
243      var R = 3440.065;
244      var dLat = (lat2 - lat1) * Math.PI / 180, dLon = (lon2 - lon1) * Math.PI / 180;
245      var a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2) * Math.sin(dLon/2);
246      return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
247    }
248  })();
249  </script>
249
250</body>
251</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.