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.