1<!DOCTYPE html> 2<html lang="en" data-theme="dark"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 7 <!-- âââ SEO âââââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 8 <title>BBoxer â Draw, snap & copy bounding-box coordinates in any EPSG</title> 9 <meta name="description" content="Free online tool to draw, snap and instantly copy bounding-box (bbox) and AOI coordinates in EPSG:4326 or any projected CRS. Draw a rectangle, polygon, upload GeoJSON, or pick a country â get the bbox as Python list or WKT, with on-the-fly proj4 reprojection."> 10 <meta name="keywords" content="bbox, bounding box, AOI, area of interest, EPSG, coordinates, GIS, GeoJSON, MapLibre, proj4, reprojection, country bbox, Kashmir, disputed regions, Natural Earth, remote sensing, satellite data, rasterio, gdal, shapely, leaflet, xarray"> 11 <meta name="author" content="Pavan Muguda Sanjeevamurthy"> 12 <meta name="robots" content="index, follow"> 13 <link rel="canonical" href="https://pmuguda.github.io/bboxer/"> 14 <link rel="manifest" href="manifest.json"> 15 <link rel="apple-touch-icon" href="icons/apple-touch-icon.png"> 16 <meta name="mobile-web-app-capable" content="yes"> 17 <meta name="apple-mobile-web-app-capable" content="yes"> 18 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 19 <meta name="apple-mobile-web-app-title" content="BBoxer"> 20 <meta name="theme-color" content="#1b2031"> 21 22 <!-- Open Graph (LinkedIn, Slack, Discord, WhatsApp link previews) --> 23 <meta property="og:type" content="website"> 24 <meta property="og:title" content="BBoxer â Copy AOI/bbox coordinates in any EPSG"> 25 <meta property="og:description" content="Draw a bbox, polygon, upload GeoJSON or pick a country â instantly get coordinates in EPSG:4326 or any projected CRS."> 26 <meta property="og:url" content="https://pmuguda.github.io/bboxer/"> 27 <meta property="og:site_name" content="BBoxer"> 28 <meta property="og:image" content="https://pmuguda.github.io/bboxer/og-preview.png"> 29 30 <!-- Twitter / X card --> 31 <meta name="twitter:card" content="summary_large_image"> 32 <meta name="twitter:title" content="BBoxer â Copy AOI/bbox coordinates in any EPSG"> 33 <meta name="twitter:description" content="Draw a bbox, polygon, upload GeoJSON or pick a country â instantly get coordinates in EPSG:4326 or any projected CRS."> 34 <meta name="twitter:image" content="https://pmuguda.github.io/bboxer/og-preview.png"> 35 36 <!-- JSON-LD structured data â helps Google show a rich result card --> 37
37<script type="application/ld+json"> 38 { 39 "@context": "https://schema.org", 40 "@type": "WebApplication", 41 "name": "BBoxer", 42 "url": "https://pmuguda.github.io/bboxer/", 43 "applicationCategory": "DesignApplication", 44 "operatingSystem": "Any (browser-based)", 45 "description": "Draw, snap and instantly copy bounding-box (bbox) and AOI coordinates in EPSG:4326 or any projected CRS. Supports bbox draw, polygon draw, GeoJSON upload, country picker, on-the-fly proj4 reprojection, and dynamic lat/lon graticule.", 46 "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, 47 "author": { 48 "@type": "Person", 49 "name": "Pavan Muguda Sanjeevamurthy", 50 "url": "https://pmuguda.github.io/" 51 }, 52 "featureList": [ 53 "Draw bounding box (click-drag)", 54 "Draw polygon", 55 "Upload GeoJSON", 56 "Country picker with disputed-regions overlay", 57 "On-the-fly EPSG reprojection via proj4js", 58 "Copy as Python list or WKT polygon", 59 "W,S,E,N and W,E,S,N axis-order toggle", 60 "Dynamic lat/lon graticule with degree labels" 61 ] 62 } 63 </script>
63 64 65 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/maplibre-gl.css" 66 crossorigin="anonymous"> 67 <link rel="preconnect" href="https://fonts.googleapis.com"> 68 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 69 <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600;700&display=swap" rel="stylesheet"> 70 <style> 71 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 72 73 :root { 74 --bg: oklch(0.15 0.008 250); 75 --surface: oklch(0.182 0.009 250); 76 --bg-3: oklch(0.225 0.011 250); 77 --border: oklch(0.31 0.012 250); 78 --border-2: oklch(0.42 0.014 250); 79 --text: oklch(0.93 0.008 250); 80 --text-2: oklch(0.71 0.011 250); 81 --muted: oklch(0.54 0.012 250); 82 --accent: oklch(0.83 0.135 82); 83 --accent-2: oklch(0.83 0.135 82 / 0.16); 84 --accent-3: oklch(0.83 0.135 82 / 0.45); 85 --red: oklch(0.65 0.20 25); 86 --shadow: 0 18px 50px -24px oklch(0 0 0 / 0.85); 87 --amber: oklch(0.75 0.16 70); 88 --sidebar-w: 320px; 89 --font-mono: 'IBM Plex Mono', ui-monospace, monospace; 90 --font-cond: 'IBM Plex Sans Condensed', 'IBM Plex Mono', sans-serif; 91 } 92 93 [data-theme="paper"] { 94 --bg: oklch(0.95 0.006 95); 95 --surface: oklch(0.975 0.005 95); 96 --bg-3: oklch(0.915 0.008 95); 97 --border: oklch(0.83 0.012 95); 98 --border-2: oklch(0.70 0.014 95); 99 --text: oklch(0.27 0.012 255); 100 --text-2: oklch(0.44 0.013 255); 101 --muted: oklch(0.57 0.012 255); 102 --accent: oklch(0.58 0.155 52); 103 --accent-2: oklch(0.58 0.155 52 / 0.14); 104 --accent-3: oklch(0.58 0.155 52 / 0.50); 105 --red: oklch(0.55 0.20 25); 106 --shadow: 0 18px 50px -28px oklch(0 0 0 / 0.35); 107 --amber: oklch(0.62 0.17 65); 108 } 109 110 html, body { height: 100%; width: 100%; overflow: hidden; } 111 body { 112 display: flex; 113 background: var(--bg); 114 color: var(--text); 115 font-size: 13px; 116 font-family: var(--font-mono); 117 -webkit-font-smoothing: antialiased; 118 text-rendering: optimizeLegibility; 119 } 120 121 /* ââ Sidebar âââââââââââââââââââââââââââââ */ 122 #sidebar { 123 width: var(--sidebar-w); 124 min-width: var(--sidebar-w); 125 height: 100vh; 126 background: var(--surface); 127 border-right: 1px solid var(--border); 128 display: flex; 129 flex-direction: column; 130 overflow-y: auto; 131 overflow-x: hidden; 132 z-index: 10; 133 scrollbar-width: thin; 134 scrollbar-color: var(--border) transparent; 135 transition: width 0.28s ease, min-width 0.28s ease; 136 position: relative; 137 } 138 #sidebar::after { 139 content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0; 140 background: repeating-linear-gradient(0deg, transparent 0 3px, oklch(1 0 0 / .008) 3px 4px); 141 } 142 #sidebar > * { position: relative; z-index: 1; } 143 #sidebar::-webkit-scrollbar { width: 9px; } 144 #sidebar::-webkit-scrollbar-thumb { background: var(--border); border: 3px solid var(--surface); } 145 #sidebar::-webkit-scrollbar-track { background: transparent; } 146 147 body.sidebar-collapsed #sidebar { 148 width: 0; min-width: 0; overflow: hidden; border-right: none; 149 } 150 151 /* ââ Map ââââââââââââââââââââââââââââââââââ */ 152 #map { flex: 1; height: 100vh; } 153 154 /* ââ Header / Brand âââââââââââââââââââââââ */ 155 .sidebar-header { 156 display: flex; align-items: flex-start; gap: 11px; 157 padding: 20px 20px 18px; 158 border-bottom: 1px solid var(--border); 159 flex-shrink: 0; 160 } 161 .logo-mark { 162 width: 46px; height: 46px; flex-shrink: 0; overflow: hidden; 163 } 164 .logo-mark img { width: 46px; height: 46px; display: block; } 165 .sidebar-brand { 166 flex: 1; min-width: 0; min-height: 46px; 167 display: flex; flex-direction: column; 168 } 169 .sidebar-header h1 { 170 font-family: var(--font-cond); font-weight: 700; font-size: 20px; 171 letter-spacing: 0.01em; color: var(--text); line-height: 1; 172 display: flex; align-items: baseline; gap: 0.08em; 173 } 174 .header-sep { color: var(--accent); font-weight: 600; } 175 .sidebar-header p { 176 margin-top: auto; padding-top: 8px; 177 font-size: 10px; letter-spacing: 0.04em; line-height: 1.35; 178 color: var(--text-2); 179 } 180 .header-right { 181 display: flex; align-items: flex-start; gap: 5px; flex-shrink: 0; 182 padding-top: 1px; 183 } 184 .theme-toggle { 185 display: flex; border: 1px solid var(--border); height: 22px; 186 font-family: var(--font-mono); font-size: 8.5px; letter-spacing: .08em; 187 } 188 .theme-toggle button { 189 appearance: none; background: transparent; border: 0; color: var(--muted); 190 padding: 0 7px; cursor: pointer; font: inherit; text-transform: uppercase; 191 } 192 .theme-toggle button + button { border-left: 1px solid var(--border); } 193 .theme-toggle button[aria-pressed="true"] { background: var(--accent); color: oklch(0.15 0.008 250); font-weight: 600; } 194 .theme-toggle button:hover:not([aria-pressed="true"]) { color: var(--text); } 195 196 /* ââ Module section headers âââââââââââââââ */ 197 .panel-section { 198 padding: 16px 20px 18px; 199 border-bottom: 1px solid var(--border); 200 } 201 .mod-h { 202 display: flex; align-items: center; gap: 9px; margin-bottom: 14px; 203 font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.2em; 204 text-transform: uppercase; color: var(--muted); 205 } 206 .mod-h .ix { color: var(--accent); font-weight: 600; } 207 .mod-h .ttl { color: var(--text-2); font-weight: 500; } 208 .mod-h .rule { flex: 1; height: 1px; background: var(--border); } 209 .section-hint { font-size: 11px; color: var(--muted); line-height: 1.6; letter-spacing: 0.02em; } 210 211 /* ââ CRS ââââââââââââââââââââââââââââââââââ */ 212 .crs-row { 213 display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; 214 } 215 .epsg-badge { 216 display: inline-flex; align-items: center; 217 padding: 3px 9px; 218 background: oklch(0.83 0.135 82 / 0.12); 219 border: 1px solid oklch(0.83 0.135 82 / 0.35); 220 color: var(--accent); font-size: 11px; font-weight: 600; 221 font-family: var(--font-mono); letter-spacing: 0.04em; 222 } 223 .crs-arrow { color: var(--muted); font-size: 13px; } 224 .epsg-input-wrap { display: flex; align-items: center; gap: 4px; } 225 .epsg-prefix { font-size: 11px; color: var(--muted); font-family: var(--font-mono); } 226 #custom-epsg { 227 width: 76px; padding: 4px 8px; 228 border: 1px solid var(--border-2); 229 background: var(--bg-3); color: var(--text); 230 font-size: 12px; font-family: var(--font-mono); 231 transition: border-color 0.15s; 232 } 233 #custom-epsg:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-2); } 234 #custom-epsg::placeholder { color: var(--muted); } 235 #epsg-status { font-size: 11px; min-height: 15px; letter-spacing: 0.03em; } 236 #epsg-status.ok { color: var(--accent); } 237 #epsg-status.err { color: var(--red); } 238 #epsg-status.loading { color: var(--muted); font-style: italic; } 239 240 /* ââ Tool grid ââââââââââââââââââââââââââââ */ 241 .tool-grid { 242 display: grid; grid-template-columns: 1fr 1fr 1fr; 243 gap: 6px; margin-bottom: 8px; 244 } 245 .tool-btn, .upload-label { 246 display: flex; flex-direction: column; align-items: center; gap: 5px; 247 padding: 9px 4px; 248 border: 1px solid var(--border-2); 249 background: var(--bg-3); color: var(--muted); 250 font-size: 9.5px; font-weight: 600; font-family: var(--font-mono); 251 letter-spacing: 0.06em; text-transform: uppercase; 252 cursor: pointer; transition: all 0.15s; text-align: center; line-height: 1.2; 253 user-select: none; 254 } 255 .tool-btn svg, .upload-label svg { width: 18px; height: 18px; } 256 .tool-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-2); } 257 .upload-label:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-2); } 258 .tool-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-2); box-shadow: 0 0 0 2px oklch(0.83 0.135 82 / 0.15); } 259 .clear-btn { 260 width: 100%; padding: 7px; 261 border: 1px solid var(--border); 262 background: transparent; color: var(--muted); 263 font-size: 10px; font-family: var(--font-mono); 264 letter-spacing: 0.1em; text-transform: uppercase; 265 cursor: pointer; transition: all 0.15s; 266 } 267 .clear-btn:hover { border-color: var(--red); color: var(--red); background: oklch(0.65 0.20 25 / 0.08); } 268 269 /* ââ Country picker toggle ââââââââââââââââ */ 270 .toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } 271 .toggle-label { font-size: 12px; color: var(--text); font-weight: 500; display:inline-flex; align-items:center; gap:6px; letter-spacing: 0.02em; } 272 .info-btn { 273 display: inline-flex; align-items: center; justify-content: center; 274 width: 18px; height: 18px; padding: 0; 275 border: none; background: transparent; 276 color: var(--muted); cursor: pointer; transition: color 0.15s; 277 } 278 .info-btn svg { width: 14px; height: 14px; pointer-events: none; } 279 .info-btn:hover, .info-btn.active { color: var(--accent); } 280 .info-popover { 281 margin-top: 8px; padding: 10px 12px; 282 background: var(--bg); border: 1px solid var(--border); 283 font-size: 11px; line-height: 1.65; color: var(--muted); letter-spacing: 0.02em; 284 } 285 .info-popover[hidden] { display: none; } 286 /* square toggle */ 287 .toggle-switch { position: relative; width: 34px; height: 20px; cursor: pointer; flex-shrink: 0; } 288 .toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; } 289 .toggle-track { 290 position: absolute; inset: 0; 291 background: var(--border); border: 1px solid var(--border-2); 292 transition: background 0.2s, border-color 0.2s; 293 } 294 .toggle-thumb { 295 position: absolute; width: 14px; height: 14px; 296 background: var(--muted); top: 2px; left: 2px; 297 transition: transform 0.2s, background 0.2s; 298 } 299 .toggle-switch input:checked ~ .toggle-track { background: oklch(0.83 0.135 82 / 0.25); border-color: var(--accent); } 300 .toggle-switch input:checked ~ .toggle-thumb { transform: translateX(14px); background: var(--accent); } 301 /* ââ Coordinates section ââââââââââââââââââ */ 302 #coords-section { display: none; } 303 #coords-section.visible { display: block; } 304 305 .coord-meta { 306 display: flex; align-items: center; gap: 6px; 307 margin-bottom: 10px; flex-wrap: wrap; 308 } 309 .coord-type-icon { width: 14px; height: 14px; color: var(--accent); flex-shrink: 0; } 310 .coord-source-text { font-size: 11px; color: var(--muted); letter-spacing: 0.02em; } 311 .coord-source-text strong { color: var(--text); font-weight: 500; max-width: 160px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; } 312 313 .precision-row { 314 display: flex; align-items: center; gap: 8px; margin-bottom: 10px; 315 } 316 .precision-row label { font-size: 9.5px; color: var(--muted); white-space: nowrap; letter-spacing: 0.12em; text-transform: uppercase; } 317 #precision-slider { flex: 1; accent-color: var(--accent); cursor: pointer; } 318 .precision-val { font-size: 11px; color: var(--accent); font-family: var(--font-mono); min-width: 10px; } 319 320 .order-row { 321 display: flex; align-items: center; justify-content: space-between; 322 gap: 8px; margin-bottom: 12px; 323 } 324 .order-label { font-size: 9.5px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; } 325 .order-pills { 326 display: flex; border: 1px solid var(--border-2); 327 overflow: hidden; flex-shrink: 0; background: var(--bg-3); 328 } 329 .order-pill { 330 padding: 4px 10px; font-size: 10px; font-weight: 600; 331 border: none; background: transparent; color: var(--muted); 332 cursor: pointer; transition: background 0.15s, color 0.15s; 333 letter-spacing: 0.08em; line-height: 1.4; font-family: var(--font-mono); 334 text-transform: uppercase; 335 } 336 .order-pill + .order-pill { border-left: 1px solid var(--border-2); } 337 .order-pill.active { background: var(--accent); color: oklch(0.15 0.008 250); font-weight: 600; } 338 .order-pill:not(.active):hover { color: var(--text); background: oklch(1 0 0 / 0.07); } 339 340 .units-chip { 341 padding: 2px 7px; 342 background: oklch(0.54 0.012 250 / 0.15); 343 border: 1px solid oklch(0.54 0.012 250 / 0.3); 344 color: var(--muted); font-size: 10px; font-weight: 600; 345 font-family: var(--font-mono); margin-left: 6px; letter-spacing: 0.04em; 346 } 347 348 .download-row { margin-top: 10px; } 349 .download-btn { 350 width: 100%; 351 display: flex; align-items: center; justify-content: center; gap: 7px; 352 padding: 8px 12px; 353 border: 1px solid oklch(0.83 0.135 82 / 0.4); 354 background: oklch(0.83 0.135 82 / 0.08); 355 color: var(--accent); 356 font-size: 10.5px; font-weight: 600; font-family: var(--font-mono); 357 letter-spacing: 0.12em; text-transform: uppercase; 358 cursor: pointer; transition: all 0.15s; 359 } 360 .download-btn svg { width: 14px; height: 14px; } 361 .download-btn:hover { background: var(--accent-2); border-color: var(--accent); } 362 .download-btn.flash { background: oklch(0.83 0.135 82 / 0.22); } 363 .dl-crs-tag { 364 padding: 1px 6px; 365 background: oklch(0.83 0.135 82 / 0.18); 366 font-family: var(--font-mono); 367 font-size: 10px; font-weight: 700; 368 letter-spacing: 0.06em; 369 } 370 .download-crs-pills { 371 display: flex; align-items: center; gap: 8px; 372 margin-top: 6px; justify-content: flex-end; 373 font-size: 10px; color: var(--muted); 374 } 375 .download-crs-pills::before { 376 content: 'Output CRS'; 377 margin-right: auto; 378 font-size: 9.5px; color: var(--muted); 379 letter-spacing: 0.1em; text-transform: uppercase; 380 } 381 .download-crs-pills .order-pill[hidden] { display: none; } 382 383 .format-card { 384 background: var(--bg); border: 1px solid var(--border); 385 padding: 9px 11px; margin-bottom: 5px; 386 } 387 .format-label { 388 font-size: 9px; font-weight: 700; text-transform: uppercase; 389 letter-spacing: 0.14em; color: var(--muted); margin-bottom: 5px; 390 } 391 .format-value-row { display: flex; align-items: flex-start; gap: 6px; } 392 .format-value { 393 flex: 1; font-size: 11px; 394 font-family: var(--font-mono); 395 color: var(--text); word-break: break-all; line-height: 1.6; 396 letter-spacing: 0.02em; 397 } 398 .copy-btn { 399 flex-shrink: 0; display: flex; align-items: center; gap: 3px; 400 padding: 3px 8px; 401 border: 1px solid var(--border); 402 background: transparent; color: var(--muted); 403 font-size: 9px; font-family: var(--font-mono); 404 letter-spacing: 0.1em; text-transform: uppercase; 405 cursor: pointer; transition: all 0.15s; white-space: nowrap; 406 } 407 .copy-btn svg { width: 10px; height: 10px; } 408 .copy-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-2); } 409 .copy-btn.copied { border-color: var(--accent); color: var(--accent); background: var(--accent-2); } 410 411 /* ââ Transformed section âââââââââââââââââââ */ 412 #transformed-section { display: none; margin-top: 10px; } 413 #transformed-section.visible { display: block; } 414 .transform-header { 415 display: flex; align-items: center; gap: 6px; margin-bottom: 8px; 416 font-size: 9.5px; color: var(--muted); letter-spacing: 0.14em; text-transform: uppercase; 417 } 418 .crs-chip { 419 padding: 2px 7px; 420 background: oklch(0.83 0.135 82 / 0.12); border: 1px solid oklch(0.83 0.135 82 / 0.35); 421 color: var(--accent); font-size: 10px; font-weight: 700; font-fami
421ly: var(--font-mono); 422 letter-spacing: 0.06em; 423 } 424 425 /* ââ Footer ââââââââââââââââââââââââââââââââ */ 426 .sidebar-footer { 427 margin-top: auto; padding: 13px 20px; 428 font-size: 10px; color: var(--muted); 429 border-top: 1px solid var(--border); flex-shrink: 0; 430 letter-spacing: 0.06em; 431 } 432 .footer-meta { 433 display: flex; align-items: center; justify-content: space-between; gap: 8px; 434 margin-bottom: 8px; 435 } 436 .footer-byline { 437 min-width: 0; 438 font-size: 10px; color: var(--muted); letter-spacing: 0.05em; 439 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 440 } 441 .footer-author { 442 color: var(--text-2); text-decoration: underline; 443 text-underline-offset: 2px; text-decoration-thickness: 1px; 444 } 445 .footer-icons { display: flex; align-items: center; gap: 6px; } 446 .footer-icon-link { 447 display: flex; align-items: center; justify-content: center; 448 width: 26px; height: 26px; 449 border: 1px solid var(--border); background: var(--surface); color: var(--text-2); 450 text-decoration: none; transition: color 0.15s, border-color 0.15s, background 0.15s; 451 } 452 .footer-icon-link:hover { background: var(--bg-3); color: var(--accent); border-color: var(--accent); } 453 .footer-support { 454 display: inline-block; 455 font-size: 10px; color: var(--muted); 456 letter-spacing: 0.06em; text-decoration: none; 457 } 458 .footer-support:hover { color: var(--accent); } 459 460 /* ââ Sidebar toggle tab ââââââââââââââââââ */ 461 #sidebar-toggle { 462 position: fixed; left: var(--sidebar-w); top: 50%; 463 transform: translateY(-50%); 464 z-index: 30; width: 22px; height: 46px; padding: 0; 465 background: var(--surface); border: 1px solid var(--border); 466 color: var(--muted); cursor: pointer; 467 display: flex; align-items: center; justify-content: center; 468 transition: left 0.28s ease, color 0.15s, border-color 0.15s; 469 } 470 #sidebar-toggle:hover { color: var(--text); border-color: var(--accent); } 471 #sidebar-toggle svg { width: 10px; height: 10px; transition: transform 0.28s; } 472 body.sidebar-collapsed #sidebar-toggle { left: 0; } 473 body.sidebar-collapsed #sidebar-toggle svg { transform: rotate(180deg); } 474 475 /* ââ Floating AOI toolbar âââââââââââââââ */ 476 #aoi-toolbar { 477 position: fixed; top: 14px; right: 16px; z-index: 20; 478 display: flex; align-items: center; gap: 1px; 479 background: oklch(from var(--surface) l c h / .9); border: 1px solid var(--border); 480 padding: 3px; 481 box-shadow: var(--shadow); 482 } 483 .tb-btn { 484 width: 30px; height: 30px; 485 border: 1px solid transparent; background: transparent; 486 color: var(--muted); cursor: pointer; 487 display: flex; align-items: center; justify-content: center; 488 transition: all 0.15s; 489 } 490 .tb-btn svg { width: 15px; height: 15px; pointer-events: none; } 491 .tb-btn:hover { color: var(--accent); background: var(--accent-2); border-color: var(--accent-3); } 492 .tb-btn.active { color: var(--accent); background: var(--accent-2); border-color: var(--accent); } 493 .tb-btn.country-on { color: var(--amber); background: oklch(from var(--amber) l c h / .12); border-color: oklch(from var(--amber) l c h / .5); } 494 .tb-btn.country-on:hover { color: var(--amber); background: oklch(from var(--amber) l c h / .18); border-color: var(--amber); } 495 .tb-btn.tb-clear:hover { color: var(--red); background: oklch(0.65 0.20 25 / 0.08); border-color: oklch(0.65 0.20 25 / 0.4); } 496 .tb-divider { width: 1px; height: 16px; background: var(--border); margin: 0 2px; } 497 498 /* ââ Hint banner ââââââââââââââââââââââââ */ 499 #hint-banner { 500 position: fixed; bottom: 28px; 501 left: calc(var(--sidebar-w) + 50%); transform: translateX(-50%); 502 background: oklch(0.182 0.009 250 / 0.93); border: 1px solid var(--border); 503 color: var(--text); font-size: 11px; padding: 8px 18px; 504 font-family: var(--font-mono); letter-spacing: 0.05em; 505 backdrop-filter: blur(10px); 506 z-index: 100; pointer-events: none; white-space: nowrap; 507 box-shadow: 0 4px 20px oklch(0 0 0 / 0.45); 508 opacity: 0; transition: opacity 0.2s; 509 } 510 #hint-banner.visible { opacity: 1; } 511 body.sidebar-collapsed #hint-banner { left: 50%; } 512 513 /* ââ Country tooltip âââââââââââââââââââ */ 514 #country-tooltip { 515 position: fixed; z-index: 50; pointer-events: none; 516 background: oklch(0.182 0.009 250 / 0.95); border: 1px solid var(--accent); 517 padding: 5px 11px; 518 font-size: 11.5px; font-weight: 600; color: var(--text); 519 font-family: var(--font-mono); letter-spacing: 0.04em; 520 backdrop-filter: blur(6px); display: none; 521 box-shadow: 0 2px 10px oklch(0 0 0 / 0.4); 522 } 523 524 /* ââ Loader âââââââââââââââââââââââââââ */ 525 #loader { 526 position: fixed; top: 14px; left: calc(var(--sidebar-w) + 14px); 527 background: oklch(0.182 0.009 250 / 0.95); border: 1px solid var(--border); 528 color: var(--text); font-size: 11px; padding: 7px 14px; 529 display: none; align-items: center; gap: 8px; 530 z-index: 200; backdrop-filter: blur(8px); 531 transition: left 0.28s; 532 font-family: var(--font-mono); letter-spacing: 0.05em; 533 } 534 #loader.visible { display: flex; } 535 body.sidebar-collapsed #loader { left: 24px; } 536 .spinner { 537 width: 13px; height: 13px; 538 border: 2px solid var(--border); border-top-color: var(--accent); 539 border-radius: 50%; animation: spin 0.7s linear infinite; 540 }
541 @keyframes spin { to { transform: rotate(360deg); } } 542 543 /* ââ Graticule HTML labels âââââââââââââ */ 544 #graticule-labels-html { 545 position: absolute; inset: 0; 546 pointer-events: none; z-index: 5; 547 overflow: hidden; 548 } 549 .graticule-label { 550 position: absolute; 551 font: 10px var(--font-mono); 552 color: oklch(0.71 0.011 250); 553 letter-spacing: 0.04em; 554 padding: 1px 4px; 555 background: oklch(0.182 0.009 250 / 0.6); 556 white-space: nowrap; 557 user-select: none; 558 } 559 560 /* ââ MapLibre overrides ââââââââââââââââ */ 561 .maplibregl-ctrl-attrib { font-size: 10px !important; font-family: var(--font-mono) !important; } 562 .maplibregl-canvas { cursor: grab; } 563 .maplibregl-canvas:active { cursor: grabbing; } 564 body.mode-draw .maplibregl-canvas { cursor: crosshair !important; } 565 body.mode-country .maplibregl-canvas { cursor: pointer !important; } 566 .maplibregl-ctrl-group { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: 0 !important; } 567 .maplibregl-ctrl-group button { color: var(--muted) !important; } 568 .maplibregl-ctrl-group button:hover { background: oklch(1 0 0 / 0.07) !important; color: var(--text) !important; } 569 .maplibregl-ctrl-scale { background: var(--surface) !important; border-color: var(--border) !important; color: var(--muted) !important; font-size: 10px !important; font-family: var(--font-mono) !important; border-radius: 0 !important; } 570 571 /* ââ Tour · OpenSAR-triad aesthetic âââââââ */ 572 /* shared design tokens scoped to tour elements */ 573 #tour-welcome, #tour-card, #tour-help { 574 --tw-bg: oklch(0.15 0.008 250); 575 --tw-bg2: oklch(0.182 0.009 250); 576 --tw-bg3: oklch(0.225 0.011 250); 577 --tw-ln: oklch(0.31 0.012 250); 578 --tw-ln2: oklch(0.42 0.014 250); 579 --tw-ink: oklch(0.93 0.008 250); 580 --tw-ink2: oklch(0.71 0.011 250); 581 --tw-ink3: oklch(0.54 0.012 250); 582 --tw-acc: oklch(0.83 0.135 82); 583 --tw-acc2: oklch(0.83 0.135 82 / 0.14); 584 font-family: 'IBM Plex Mono', ui-monospace, monospace; 585 } 586 587 /* ââ Welcome modal ââ */ 588 #tour-welcome { 589 display: none; position: fixed; inset: 0; z-index: 9100; 590 background: oklch(0 0 0 / 0.88); 591 align-items: center; justify-content: center; 592 } 593 #tour-welcome.active { display: flex; } 594 595 .tw-card { 596 position: relative; overflow: hidden; 597 background: var(--tw-bg2); border: 1px solid var(--tw-ln); 598 max-width: 370px; width: calc(100% - 32px); 599 box-shadow: 0 28px 64px -18px oklch(0 0 0 / 0.9); 600 } 601 /* scanline texture */ 602 .tw-card::after { 603 content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0; 604 background: repeating-linear-gradient( 605 0deg, transparent 0 3px, oklch(1 0 0 / .007) 3px 4px); 606 } 607 .tw-card > * { position: relative; z-index: 1; } 608 609 .tw-hd { 610 display: flex; align-items: center; gap: 13px; 611 padding: 16px 20px 15px; border-bottom: 1px solid var(--tw-ln); 612 } 613 .tw-mark { 614 width: 42px; height: 42px; flex-shrink: 0; 615 border: 1px solid var(--tw-acc); 616 display: flex; align-items: center; justify-content: center; 617 overflow: hidden; color: var(--tw-acc); 618 } 619 .tw-mark img { width: 100%; height: 100%; display: block; object-fit: cover; } 620 .tw-wordmark { 621 font-family: 'IBM Plex Sans Condensed', 'IBM Plex Mono', sans-serif; 622 font-weight: 700; font-size: 18px; letter-spacing: .02em; 623 color: var(--tw-ink); line-height: 1; 624 display: flex; align-items: baseline; gap: .1em; 625 } 626 .tw-sep { color: var(--tw-acc); font-weight: 600; } 627 .tw-sub { 628 margin-top: 5px; font-size: 9px; letter-spacing: .16em; 629 text-transform: uppercase; color: var(--tw-ink3); 630 } 631 .tw-bd { padding: 18px 20px 20px; } 632 .tw-mod-h { 633 display: flex; align-items: center; gap: 9px; margin-bottom: 13px; 634 font-size: 9.5px; letter-spacing: .2em; text-transform: uppercase; 635 color: var(--tw-ink3); 636 } 637 .tw-mod-h .ix { color: var(--tw-acc); font-weight: 600; } 638 .tw-mod-h .rule { flex: 1; height: 1px; background: var(--tw-ln); } 639 .tw-desc { 640 font-size: 11.5px; letter-spacing: .015em; color: var(--tw-ink2); 641 line-height: 1.72; margin-bottom: 20px; 642 } 643 .tw-desc strong { color: var(--tw-ink); font-weight: 500; } 644 .tw-actions { display: flex; flex-direction: column; } 645 #btn-tour-start { 646 height: 42px; background: var(--tw-bg3); 647 border: 1px solid var(--tw-ln2); 648 color: var(--tw-ink); font-family: inherit; 649 font-size: 10.5px; letter-spacing: .15em; text-transform: uppercase; 650 cursor: pointer; display: flex; align-items: center; justify-content: center; 651 gap: 10px; transition: border-color .15s, color .15s, background .15s; 652 } 653 #btn-tour-start:hover { 654 border-color: var(--tw-acc); color: var(--tw-acc); background: var(--tw-acc2); 655 } 656 #btn-tour-skip-intro { 657 height: 36px; background: transparent; 658 border: 1px solid var(--tw-ln); border-top: none; 659 color: var(--tw-ink3); font-family: inherit; 660 font-size: 9.5px; letter-spacing: .12em; text-transform: uppercase; 661 cursor: pointer; display: flex; align-items: center; justify-content: center; 662 transition: color .15s; 663 } 664 #btn-tour-skip-intro:hover { color: var(--tw-ink2); } 665 666 /* ââ Step overlay ââ */ 667 #tour-overlay { 668 display: none; position: fixed; inset: 0; z-index: 9000; 669 pointer-events: all; background: transparent; 670 } 671 #tour-overlay.active { display: block; } 672 #tour-overlay.dark-bg { background: oklch(0 0 0 / 0.82); } 673 674 /* spotlight hole â box-shadow forms the dark surround */ 675 #tour-spot { 676 display: none; position: fixed; z-index: 9001; 677 pointer-events: none; 678 box-shadow: 0 0 0 9999px oklch(0 0 0 / 0.82); 679 outline: 1.5px solid oklch(0.83 0.135 82 / 0.7); 680 outline-offset: 2px; 681 transition: left .3s cubic-bezier(.4,0,.1,1), top .3s cubic-bezier(.4,0,.1,1), 682 width .3s cubic-bezier(.4,0,.1,1), height .3s cubic-bezier(.4,0,.1,1); 683 } 684 #tour-spot.active { display: block; } 685 686 /* ââ Step card ââ */ 687 #tour-card { 688 display: none; position: fixed; z-index: 9002; 689 background: var(--tw-bg2); border: 1px solid var(--tw-ln); 690 width: 304px; overflow: hidden; 691 box-shadow: 0 22px 54px -16px oklch(0 0 0 / 0.88); 692 } 693 #tour-card::after { 694 content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0; 695 background: repeating-linear-gradient( 696 0deg, transparent 0 3px, oklch(1 0 0 / .006) 3px 4px); 697 } 698 #tour-card > * { position: relative; z-index: 1; } 699 #tour-card.active { display: block; } 700 #tour-card.tc-center { 701 top: 50% !important; left: 50% !important; 702 transform: translate(-50%, -50%); 703 } 704 705 /* module-style header */ 706 .tc-head { 707 display: flex; align-items: center; gap: 8px; 708 padding: 9px 13px 8px; border-bottom: 1px solid var(--tw-ln); 709 font-size: 9px; letter-spacing: .2em; text-transform: uppercase; 710 color: var(--tw-ink3); 711 }
712 .tc-ix { color: var(--tw-acc); font-weight: 600; } 713 .tc-lbl { color: var(--tw-ink3); } 714 .tc-head .rule { flex: 1; height: 1px; background: var(--tw-ln); } 715 #btn-tour-exit { 716 background: none; border: none; cursor: pointer; padding: 0; 717 color: var(--tw-ink3); font-family: inherit; 718 font-size: 9px; letter-spacing: .12em; text-transform: uppercase; 719 transition: color .15s; 720 } 721 #btn-tour-exit:hover { color: var(--tw-ink); } 722 723 /* body */ 724 .tc-bw { padding: 14px 16px 15px; } 725 .tc-title { 726 font-family: 'IBM Plex Sans Condensed', 'IBM Plex Mono', sans-serif; 727 font-weight: 700; font-size: 15px; letter-spacing: .02em; 728 color: var(--tw-ink); margin-bottom: 9px; line-height: 1.25; 729 } 730 .tc-body { 731 font-size: 11px; letter-spacing: .02em; line-height: 1.72; 732 color: var(--tw-ink2); 733 } 734 735 /* footer nav */ 736 .tc-foot { 737 display: grid; grid-template-columns: 1fr auto 1fr; 738 align-items: stretch; border-top: 1px solid var(--tw-ln); 739 } 740 .tc-btn { 741 height: 34px; background: transparent; border: none; 742 color: var(--tw-ink3); font-family: inherit; 743 font-size: 9.5px; letter-spacing: .13em; text-transform: uppercase; 744 cursor: pointer; display: flex; align-items: center; justify-content: center; 745 transition: color .15s, background .15s; 746 } 747 .tc-btn:hover:not(:disabled) { color: var(--tw-acc); background: var(--tw-acc2); } 748 .tc-btn:disabled { opacity: 0.25; pointer-events: none; } 749 #btn-tour-prev { border-right: 1px solid var(--tw-ln); } 750 .tc-next { border-left: 1px solid var(--tw-ln); } 751 752 /* progress dots (centre of footer) */ 753 .tc-dots { display: flex; gap: 3px; align-items: center; padding: 0 10px; } 754 .tc-dot { 755 width: 4px; height: 4px; background: var(--tw-ln2); 756 transition: background .2s, width .2s; 757 } 758 .tc-dot.active { background: var(--tw-acc); width: 10px; } 759 760 /* ââ Help / replay button ââ */ 761 #tour-help { 762 position: fixed; top: 16px; left: calc(var(--sidebar-w) + 16px); 763 z-index: 40; 764 width: 32px; height: 32px; flex-shrink: 0; 765 background: oklch(from var(--surface) l c h / .86); border: 1px solid var(--border); 766 color: var(--text-2); font-family: var(--font-mono); 767 font-size: 12px; font-weight: 600; 768 box-shadow: var(--shadow); 769 cursor: pointer; display: flex; align-items: center; justify-content: center; 770 transition: left 0.28s, border-color .15s, color .15s; 771 } 772 #tour-help:hover { border-color: var(--accent); color: var(--accent); } 773 #tour-help.th-hidden { display: none; } 774 body.sidebar-collapsed #tour-help { left: 16px; } 775 </style> 776</head> 777<body> 778 779<!-- âââââââââââââââââââââââââââââââ SIDEBAR âââââââââââââââââââââââââââââââ --> 780<aside id="sidebar"> 781 <header class="sidebar-header"> 782 <div class="logo-mark"><img src="logo.svg" alt="BBoxer"></div> 783 <div class="sidebar-brand"> 784 <h1>b<span class="header-sep">·</span>boxer</h1> 785 <p>Bounding-box discovery console</p> 786 </div> 787 <div class="header-right"> 788 <div class="theme-toggle" role="group" aria-label="Theme"> 789 <button data-theme-btn="dark" aria-pressed="true">DARK</button> 790 <button data-theme-btn="paper" aria-pressed="false">PAPER</button> 791 </div> 792 </div> 793 </header> 794 795 <!-- CRS --> 796 <div class="panel-section"> 797 <div class="mod-h"><span class="ix">01</span><span class="ttl">CRS</span><span class="rule"></span></div> 798 <div class="crs-row"> 799 <span class="epsg-badge">EPSG:4326</span> 800 <span class="units-chip" id="src-units-chip">°</span> 801 <span class="crs-arrow">â</span> 802 <div class="epsg-input-wrap"> 803 <span class="epsg-prefix">EPSG:</span> 804 <input type="text" id="custom-epsg" placeholder="32633" maxlength="6" title="Type an EPSG code to transform coordinates"> 805 </div> 806 </div> 807 <div id="epsg-status"></div> 808 </div> 809 810 <!-- Coordinates --> 811 <div class="panel-section" id="coords-section"> 812 <div class="mod-h"><span class="ix">02</span><span class="ttl">Coordinates</span><span class="rule"></span></div> 813 814 <div class="coord-meta">
815 <svg class="coord-type-icon" id="coord-icon" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 816 <rect x="1" y="1" width="12" height="12" rx="1.5"/> 817 </svg> 818 <span class="coord-source-text">Source: <strong id="coord-source-label">â</strong></span> 819 </div> 820 821 <div class="precision-row"> 822 <label for="precision-slider">Precision</label> 823 <input type="range" id="precision-slider" min="0" max="8" value="6"> 824 <span class="precision-val" id="precision-val">6</span> 825 </div> 826 827 <div class="order-row"> 828 <span class="order-label">Order</span> 829 <div class="order-pills"> 830 <button class="order-pill active" data-order="wsen" title="minX, minY, maxX, maxY â rasterio / shapely / leaflet">W, S, E, N</button> 831 <button class="order-pill" data-order="wesn" title="minX, maxX, minY, maxY â matplotlib extent / xarray slicing">W, E, S, N</button> 832 </div> 833 </div> 834 835 <div id="format-list"></div> 836 837 <div class="download-row"> 838 <button class="download-btn" id="btn-download-geojson" title="Download bbox as GeoJSON"> 839 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"> 840 <path d="M8 2v9M4 7l4 4 4-4"/> 841 <path d="M2 13h12"/> 842 </svg> 843 Download GeoJSON 844 <span class="dl-crs-tag" id="dl-crs-tag">EPSG:4326</span> 845 </button> 846 <div class="download-crs-pills" id="download-crs-pills"> 847 <button class="order-pill active" data-dl="4326" title="Standard GeoJSON in WGS84 â works in every tool">4326</button> 848 <button class="order-pill" data-dl="custom" id="dl-pill-custom" title="Projected CRS â includes legacy `crs` member (GDAL/QGIS recognise it; some tools may not)" hidden>EPSG:â</button> 849 </div> 850 </div> 851 852 <div id="transformed-section"> 853 <div class="transform-header"> 854 <span>Transformed â</span> 855 <span class="crs-chip" id="tx-chip">EPSG:â</span> 856 </div> 857 <div id="format-list-tx"></div> 858 </div> 859 </div> 860 861 <!-- Footer --> 862 <footer class="sidebar-footer"> 863 <div class="footer-meta"> 864 <div class="footer-byline"> 865 By <a href="https://pmuguda.github.io" target="_blank" rel="noopener" class="footer-author">Pavan Muguda Sanjeevamurthy</a> 866 </div> 867 <span class="footer-icons"> 868 <a href="https://github.com/pmuguda/bboxer" target="_blank" rel="noopener" title="GitHub" class="footer-icon-link"> 869 <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-label="GitHub"> 870 <path d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.605-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12c0-5.523-4.477-10-10-10z"/> 871 </svg> 872 </a> 873 <a href="https://ko-fi.com/pavan_muguda" target="_blank" rel="noopener" title="Ko-fi" class="footer-icon-link"> 874 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-label="Ko-fi"> 875 <path d="M4 8h13v5a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8z"/> 876 <path d="M17 9h2.5a2.5 2.5 0 0 1 0 5H17"/> 877 <path d="M7 3v2M11 3v2"/> 878 </svg> 879 </a> 880 </span> 881 </div> 882 <a href="https://ko-fi.com/pavan_muguda" target="_blank" rel="noopener" class="footer-support">if it helped, support me on Ko-fi</a> 883 </footer> 884</aside> 885 886<button id="tour-help" class="th-hidden" title="Replay tour">?</button> 887 888<!-- Sidebar toggle --> 889<button id="sidebar-toggle" title="Toggle sidebar" aria-label="Toggle sidebar"> 890 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"> 891 <path d="M10 3L5 8l5 5"/> 892 </svg> 893</button> 894 895<!-- ââââââââââââââââââââââââââââââââââ MAP ââââââââââââââââââââââââââââââââââ --> 896<div id="map"></div> 897 898<!-- Floating AOI toolbar --> 899<div id="aoi-toolbar"> 900 901 <button class="tb-btn" id="tb-bbox" title="Draw BBox"> 902 <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> 903 <rect x="3" y="3" width="14" height="14" rx="1"/> 904 <circle cx="3" cy="3" r="1.6" fill="currentColor" stroke="none"/> 905 <circle cx="17" cy="3" r="1.6" fill="currentColor" stroke="none"/> 906 <circle cx="3" cy="17" r="1.6" fill="currentColor" stroke="none"/> 907 <circle cx="17" cy="17" r="1.6" fill="currentColor" stroke="none"/> 908 </svg> 909 </button> 910 911 <button class="tb-btn" id="tb-poly" title="Draw Polygon"> 912 <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> 913 <polygon points="10,2 18,8 15,17 5,17 2,8"/> 914 </svg> 915 </button> 916 917 <div class="tb-divider"></div> 918 919 <label class="tb-btn" title="Upload GeoJSON" style="cursor:pointer"> 920 <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> 921 <path d="M10 13V3M6 9l4-6 4 6"/><path d="M3 16h14"/> 922 </svg> 923 <input type="file" id="tb-upload" accept=".geojson,.json" hidden> 924 </label> 925 926 <div class="tb-divider"></div> 927 928 <button class="tb-btn" id="tb-country" title="Country Picker"> 929 <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> 930 <circle cx="10" cy="10" r="8"/> 931 <path d="M2 10h16"/> 932 <path d="M10 2a14 14 0 010 16"/> 933 <path d="M10 2a14 14 0 000 16"/> 934 </svg> 935 </button> 936 937 <div class="tb-divider"></div> 938 939 <button class="tb-btn tb-clear" id="tb-clear" title="Clear all"> 940 <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 941 <path d="M5 5l10 10M15 5L5 15"/> 942 </svg> 943 </button> 944 945</div> 946 947<!-- Hint banner --> 948<div id="hint-banner"></div> 949 950<!-- Country tooltip --> 951<div id="country-tooltip"></div> 952 953<!-- Loader --> 954<div id="loader"> 955 <div class="spinner"></div> 956 <span id="loader-text">Loadingâ¦</span> 957</div> 958 959<!-- âââââââââââââââââââââââââââââââ SCRIPTS ââââââââââââââââââââââââââââââââ -->
960<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/maplibre-gl.js" 961 crossorigin="anonymous"></script>
vendor: 1 bytes, line 961
961
962<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/proj4.js" 963 crossorigin="anonymous"></script>
vendor: 1 bytes, line 963
963
964<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/topojson-client.min.js" 965 crossorigin="anonymous"></script>
vendor: 1 bytes, line 965
965
966<script> 967'use strict'; 968 969/* âââ ISO 3166-1 numeric â country name (world-atlas 110m) âââââââââââââââ */ 970const ISO_NAMES = { 971 4:'Afghanistan',8:'Albania',12:'Algeria',24:'Angola',32:'Argentina', 972 36:'Australia',40:'Austria',50:'Bangladesh',56:'Belgium',64:'Bhutan', 973 68:'Bolivia',70:'Bosnia & Herz.',72:'Botswana',76:'Brazil',84:'Belize', 974 100:'Bulgaria',104:'Myanmar',108:'Burundi',112:'Belarus',116:'Cambodia', 975 120:'Cameroon',124:'Canada',140:'Central African Rep.',144:'Sri Lanka', 976 148:'Chad',152:'Chile',156:'China',170:'Colombia',174:'Comoros', 977 178:'Congo',180:'Dem. Rep. Congo',188:'Costa Rica',191:'Croatia', 978 192:'Cuba',196:'Cyprus',203:'Czechia',204:'Benin',208:'Denmark', 979 214:'Dominican Rep.',218:'Ecuador',222:'El Salvador',226:'Eq. Guinea', 980 231:'Ethiopia',232:'Eritrea',233:'Estonia',238:'Falkland Is.', 981 242:'Fiji',246:'Finland',250:'France',266:'Gabon',276:'Germany', 982 288:'Ghana',300:'Greece',304:'Greenland',320:'Guatemala',324:'Guinea', 983 328:'Guyana',332:'Haiti',340:'Honduras',348:'Hungary',352:'Iceland', 984 356:'India',360:'Indonesia',364:'Iran',368:'Iraq',372:'Ireland', 985 376:'Israel',380:'Italy',388:'Jamaica',392:'Japan',398:'Kazakhstan', 986 400:'Jordan',404:'Kenya',408:'North Korea',410:'South Korea',414:'Kuwait', 987 418:'Laos',422:'Lebanon',426:'Lesotho',428:'Latvia',430:'Liberia', 988 434:'Libya',440:'Lithuania',450:'Madagascar',454:'Malawi',458:'Malaysia', 989 462:'Maldives',466:'Mali',470:'Malta',478:'Mauritania',484:'Mexico', 990 496:'Mongolia',498:'Moldova',499:'Montenegro',504:'Morocco', 991 508:'Mozambique',516:'Namibia',524:'Nepal',528:'Netherlands', 992 540:'New Caledonia',554:'New Zealand',558:'Nicaragua',562:'Niger', 993 566:'Nigeria',578:'Norway',586:'Pakistan',591:'Panama', 994 598:'Papua New Guinea',600:'Paraguay',604:'Peru',608:'Philippines', 995 616:'Poland',620:'Portugal',624:'Guinea-Bissau',630:'Puerto Rico', 996 634:'Qatar',642:'Romania',643:'Russia',646:'Rwanda',682:'Saudi Arabia', 997 686:'Senegal',688:'Serbia',694:'Sierra Leone',702:'Singapore', 998 703:'Slovakia',705:'Slovenia',706:'Somalia',710:'South Africa', 999 716:'Zimbabwe',724:'Spain',728:'South Sudan',729:'Sudan',740:'Suriname', 1000 748:'Eswatini',752:'Sweden',756:'Switzerland',760:'Syria',764:'Thailand', 1001 768:'Togo',780:'Trinidad & Tobago',788:'Tunisia',792:'Turkey', 1002 800:'Uganda',804:'Ukraine',784:'UAE',807:'North Macedonia', 1003 818:'Egypt',826:'United Kingdom',840:'United States',858:'Uruguay', 1004 860:'Uzbekistan',862:'Venezuela',704:'Vietnam',887:'Yemen', 1005 894:'Zambia',384:'Côte d\'Ivoire',854:'Burkina Faso',90:'Solomon Is.', 1006 548:'Vanuatu',344:'Hong Kong',446:'Macao',860:'Uzbekistan', 1007}; 1008 1009/* âââ State âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1010let currentBbox = null; // [minLng, minLat, maxLng, maxLat] 1011let drawMode = null; // 'bbox' | 'poly' | null 1012let countryMode = false; 1013let bboxAnchor = null; // {lng, lat} â first click in bbox mode 1014let polyVerts = []; 1015let customEpsg = null; // number or null 1016let countriesLoaded = false; 1017let hoveredId = null; 1018let selectedId = null; 1019const countryBbox = new Map(); // id â [w, s, e, n] (kept off the feature 1020 // because MapLibre serializes properties) 1021let precision = 6; 1022let order = 'wsen'; // 'wsen' (minX,minY,maxX,maxY) | 'wesn' (minX,maxX,minY,maxY) 1023let dlCrs = '4326'; // '4326' | 'custom' â which CRS the download button writes 1024let coordLabel = ''; 1025let coordType = 'bbox'; // 'bbox' | 'poly' | 'upload' | 'country' 1026let _currentAoiFC = null; // cached for theme-change restoration 1027let _countriesFC = null; 1028let _disputedFC = null; 1029 1030/* âââ Map init âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1031const map = new maplibregl.Map({ 1032 container: 'map', 1033 style: 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json', 1034 center: [10, 20], 1035 zoom: 1.8, 1036 minZoom: 0.5, 1037}); 1038 1039map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right'); 1040map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); 1041 1042/* âââ Add data layers on load ââââââââââââââââââââââââââââââââââââââââââââââ */ 1043map.on('load', () => { 1044 /* countries (loaded lazily) */ 1045 map.addSource('countries', { type: 'geojson', data: emptyFC() }); 1046 map.addLayer({ 1047 id: 'countries-fill', type: 'fill', source: 'countries', 1048 layout: { visibility: 'none' }, 1049 paint: { 1050 'fill-color': [ 1051 'case', 1052 ['boolean', ['feature-state', 'selected'], false], '#d29922', 1053 ['boolean', ['feature-state', 'hovered'], false], '#c9941a', 1054 '#7a8090' 1055 ], 1056 'fill-opacity': [ 1057 'case', 1058 ['boolean', ['feature-state', 'selected'], false], 0.40, 1059 ['boolean', ['feature-state', 'hovered'], false], 0.28, 1060 0.05 1061 ], 1062 } 1063 }); 1064 map.addLayer({ 1065 id: 'countries-line', type: 'line', source: 'countries', 1066 layout: { visibility: 'none' }, 1067 paint: { 1068 'line-color': [ 1069 'case', 1070 ['boolean', ['feature-state', 'selected'], false], '#d29922', 1071 '#d29922' 1072 ], 1073 'line-width': [ 1074 'case', 1075 ['boolean', ['feature-state', 'selected'], false], 2, 1076 ['boolean', ['feature-state', 'hovered'], false], 1.5, 1077 0.4 1078 ], 1079 'line-opacity': 0.85, 1080 } 1081 }); 1082 1083 /* Disputed / breakaway areas â overlay on top of the country layer. 1084 Visually distinct (red + dashed white outline) so users can see at a 1085 glance which regions are contested between multiple claimants. Layer 1086 is not in the clickable layers list, so clicks pass through to the 1087 country beneath â meaning bboxes still reflect Natural Earth's 1088 territorial assignment (we flag that in the disclaimer). */ 1089 map.addSource('disputed', { type: 'geojson', data: emptyFC() }); 1090 map.addLayer({ 1091 id: 'disputed-fill', type: 'fill', source: 'disputed', 1092 layout: { visibility: 'none' }, 1093 paint: { 1094 /* Near-opaque so the selected-country fill underneath is fully 1095 masked â otherwise picking Pakistan still looks like it claims 1096 Kashmir. A muted red distinct from the bright #f85149 we use 1097 for destructive actions. */ 1098 'fill-color': '#8b2c33', 1099 'fill-opacity': 0.92, 1100 } 1101 }); 1102 map.addLayer({ 1103 id: 'disputed-line', type: 'line', source: 'disputed', 1104 layout: { visibility: 'none' }, 1105 paint: { 1106 'line-color': '#ffffff', 1107 'line-width': 1.4, 1108 'line-dasharray': [3, 2], 1109 'line-opacity': 0.85, 1110 } 1111 }); 1112 1113 /* initial graticule + keep it in sync with pan/zoom */ 1114 regenerateGraticule(); 1115 map.on('moveend', regenerateGraticule); 1116}); 1117 1118/* âââ Dynamic graticule (lat/lon grid + labels) ââââââââââââââââââââââââââââ */ 1119/* Pick a grid spacing in degrees that gives ~6-10 lines across the viewport 1120 at the current zoom level. The breakpoints follow the standard 1-2-5 1121 sequence so the grid feels predictable as you zoom. */ 1122function graticuleStep(z) { 1123 if (z < 1.2) return 30; 1124 if (z < 2.2) return 15; 1125 if (z < 3.5) return 10; 1126 if (z < 4.8) return 5; 1127 if (z < 6.2) return 2; 1128 if (z < 7.5) return 1; 1129 if (z < 9) return 0.5; 1130 if (z < 10.5) return 0.2; 1131 if (z < 12) return 0.1; 1132 if (z < 13.5) return 0.05; 1133 return 0.02; 1134} 1135 1136function formatDeg(v, kind) { 1137 const abs = Math.abs(v); 1138 const decimals = abs < 1 ? 2 : abs < 10 ? 1 : 0; 1139 const ref = kind === 'lng' ? (v >= 0 ? 'E' : 'W') : (v >= 0 ? 'N' : 'S'); 1140 /* Special-case 0 â no hemisphere */ 1141 if (v === 0) return `0°`; 1142 return `${abs.toFixed(decimals)}°${ref}`; 1143} 1144 1145/* Snap a value to the nearest multiple of step, then floor/ceil. Avoids 1146 accumulating float drift when stepping in fractional degrees (0.1, 0.05). */ 1147const snap = (v, step) => Math.round(v / step) * step; 1148 1149function regenerateGraticule() { 1150 const z = map.getZoom(); 1151 const step = graticuleStep(z); 1152 const b = map.getBounds(); 1153 const w = b.getWest(), e = b.getEast(); 1154 const s = Math.max(-85, b.getSouth()); 1155 const n = Math.min( 85, b.getNorth()); 1156 1157 const startLng = snap(w, step) - step; 1158 const endLng = snap(e, step) + step; 1159 const startLat = Math.max(-85, snap(s, step) - step); 1160 const endLat = Math.min( 85, snap(n, step) + step); 1161 1162 /* Major lines every 5 ticks (so e.g. on a 1° grid, 5°/10°/15° pop) */ 1163 const majorEvery = step * 5; 1164 const isMajor = (v) => Math.abs(snap(v, majorEvery) - v) < step / 100; 1165 1166 const lines = []; 1167 const labelData = []; // for HTML overlay 1168 1169 /* Vertical lines = constant longitude */ 1170 for (let lng = startLng; lng <= endLng + 1e-9; lng += step) { 1171 const v = +lng.toFixed(6); 1172 lines.push({ 1173 type: 'Feature', 1174 geometry: { type: 'LineString', coordinates: [[v, startLat], [v, endLat]] }, 1175 properties: { major: isMajor(v) }, 1176 }); 1177 labelData.push({ kind: 'lng', value: v, text: formatDeg(v, 'lng') }); 1178 } 1179 1180 /* Horizontal lines = constant latitude */ 1181 for (let lat = startLat; lat <= endLat + 1e-9; lat += step) { 1182 const v = +lat.toFixed(6); 1183 lines.push({ 1184 type: 'Feature', 1185 geometry: { type: 'LineString', coordinates: [[startLng, v], [endLng, v]] }, 1186 properties: { major: isMajor(v) }, 1187 }); 1188 labelData.push({ kind: 'lat', value: v, text: formatDeg(v, 'lat') }); 1189 } 1190 1191 if (map.getSource('graticule-lines')) 1192 map.getSource('graticule-lines').setData({ type:'FeatureCollection', features: lines }); 1193 1194 renderGraticuleLabelsHTML(labelData); 1195} 1196 1197/* HTML overlay labels â one absolute-positioned div per longitude (along 1198 bottom of map) and per latitude (along left edge). Pinned to the 1199 viewport edges, not to the world, so they stay visible while panning. */ 1200function renderGraticuleLabelsHTML(labelData) { 1201 let host = document.getElementById('graticule-labels-html'); 1202 if (!host) { 1203 host = document.createElement('div'); 1204 host.id = 'graticule-labels-html'; 1205 document.getElementById('map').appendChild(host); 1206 } 1207 host.innerHTML = ''; 1208 const mapEl = document.getElementById('map'); 1209 const h = mapEl.clientHeight, w = mapEl.clientWidth; 1210 const PAD_BOTTOM = 38; // clear the scale bar + attribution row 1211 const PAD_LEFT = 6; 1212 1213 labelData.forEach(lab => { 1214 const el = document.createElement('div'); 1215 el.className = 'graticule-label'; 1216 el.textContent = lab.text; 1217 1218 if (lab.kind === 'lng') { 1219 /* Project (lng, equator) and just take x; pin to bottom */ 1220 const p = map.project([lab.value, 0]); 1221 if (p.x < 0 || p.x > w) return; 1222 el.style.left = p.x + 'px'; 1223 el.style.bottom = PAD_BOTTOM + 'px'; 1224 el.style.transform = 'translateX(-50%)'; 1225 } else { 1226 const p = map.project([0, lab.value]); 1227 if (p.y < 0 || p.y > h) return; 1228 el.style.top = p.y + 'px'; 1229 el.style.left = PAD_LEFT + 'px'; 1230 el.style.transform = 'translateY(-50%)'; 1231 } 1232 host.appendChild(el); 1233 }); 1234} 1235 1236/* The AOI + preview + graticule layers belong in the original map.on('load') 1237 handler. They got separated when this region was edited â re-attach by 1238 listening for 'load' again (idempotent: addSource/addLayer throw if the 1239 id already exists, so we guard each). */
1240map.on('load', () => { 1241 /* Graticule lines as GL layer; labels are rendered as HTML overlay 1242 (sidesteps glyph-pbf / font-stack issues for the no-labels style). */ 1243 if (!map.getSource('graticule-lines')) { 1244 map.addSource('graticule-lines', { type: 'geojson', data: emptyFC() }); 1245 map.addLayer({ 1246 id: 'graticule-lines', type: 'line', source: 'graticule-lines', 1247 paint: { 1248 'line-color': '#ffffff', 1249 'line-opacity': ['case', ['get', 'major'], 0.13, 0.07], 1250 'line-width': ['case', ['get', 'major'], 0.8, 0.5], 1251 } 1252 }); 1253 } 1254 1255 /* drawn AOI */ 1256 if (!map.getSource('aoi')) { 1257 map.addSource('aoi', { type: 'geojson', data: emptyFC() }); 1258 map.addLayer({ 1259 id: 'aoi-fill', type: 'fill', source: 'aoi', 1260 paint: { 'fill-color': '#d29922', 'fill-opacity': 0.12 } 1261 }); 1262 map.addLayer({ 1263 id: 'aoi-line', type: 'line', source: 'aoi', 1264 paint: { 'line-color': '#d29922', 'line-width': 2, 'line-dasharray': [4, 2] } 1265 }); 1266 } 1267 1268 /* Derived bbox outline â rendered on top of the AOI so the user can see 1269 the bounding rectangle around polygons / uploads / country picks. */ 1270 if (!map.getSource('aoi-bbox')) { 1271 map.addSource('aoi-bbox', { type: 'geojson', data: emptyFC() }); 1272 map.addLayer({ 1273 id: 'aoi-bbox-line', type: 'line', source: 'aoi-bbox', 1274 paint: { 1275 'line-color': '#d29922', 1276 'line-width': 1.3, 1277 'line-dasharray': [6, 3], 1278 'line-opacity': 0.9, 1279 } 1280 }); 1281 } 1282 1283 /* rubber-band preview */ 1284 if (!map.getSource('preview')) { 1285 map.addSource('preview', { type: 'geojson', data: emptyFC() }); 1286 map.addLayer({ 1287 id: 'preview-fill', type: 'fill', source: 'preview', 1288 paint: { 'fill-color': '#d29922', 'fill-opacity': 0.07 } 1289 }); 1290 map.addLayer({ 1291 id: 'preview-line', type: 'line', source: 'preview', 1292 paint: { 'line-color': '#d29922', 'line-width': 1.5, 'line-dasharray': [2, 2] } 1293 }); 1294 } 1295 1296 regenerateGraticule(); 1297 map.off('moveend', regenerateGraticule); // belt-and-suspenders against double-bind 1298 map.on('moveend', regenerateGraticule); 1299}); 1300 1301/* âââ BBox draw (click-drag) âââââââââââââââââââââââââââââââââââââââââââââââ */ 1302map.on('mousedown', (e) => { 1303 if (drawMode !== 'bbox') return; 1304 e.preventDefault(); 1305 bboxAnchor = { lng: e.lngLat.lng, lat: e.lngLat.lat }; 1306}); 1307 1308map.on('mousemove', (e) => { 1309 /* bbox drag preview */ 1310 if (drawMode === 'bbox' && bboxAnchor) { 1311 setPreview(bboxToFC(toBbox(bboxAnchor.lng, bboxAnchor.lat, e.lngLat.lng, e.lngLat.lat))); 1312 return; 1313 } 1314 /* polygon rubber-band */ 1315 if (drawMode === 'poly' && polyVerts.length > 0) { 1316 const pts = [...polyVerts, [e.lngLat.lng, e.lngLat.lat], polyVerts[0]]; 1317 setPreview({ type:'FeatureCollection', features:[{ 1318 type:'Feature', geometry:{ type:'LineString', coordinates: pts }, properties:{} 1319 }]}); 1320 } 1321 /* country hover */ 1322 if (countryMode) handleCountryHover(e); 1323}); 1324 1325map.on('mouseup', (e) => { 1326 if (drawMode !== 'bbox' || !bboxAnchor) return; 1327 const bbox = toBbox(bboxAnchor.lng, bboxAnchor.lat, e.lngLat.lng, e.lngLat.lat); 1328 bboxAnchor = null; 1329 clearPreview(); 1330 if (Math.abs(bbox[2]-bbox[0]) < 0.001 && Math.abs(bbox[3]-bbox[1]) < 0.001) return; 1331 setAoiData(bboxToFC(bbox)); 1332 commitBbox(bbox, 'Drawn BBox', 'bbox'); 1333 deactivateDraw(); 1334}); 1335 1336/* âââ Polygon draw âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1337map.on('click', (e) => { 1338 if (countryMode) { handleCountryClick(e); return; } 1339 if (drawMode !== 'poly') return; 1340 polyVerts.push([e.lngLat.lng, e.lngLat.lat]); 1341}); 1342 1343map.on('dblclick', (e) => { 1344 if (drawMode !== 'poly' || polyVerts.length < 3) return; 1345 e.preventDefault(); 1346 const verts = [...polyVerts]; 1347 polyVerts = []; 1348 clearPreview(); 1349 const fc = { type:'FeatureCollection', features:[{ 1350 type:'Feature', geometry:{ type:'Polygon', coordinates:[[...verts, verts[0]]] }, properties:{} 1351 }]}; 1352 setAoiData(fc); 1353 const lngs = verts.map(v => v[0]), lats = verts.map(v => v[1]); 1354 commitBbox([Math.min(...lngs),Math.min(...lats),Math.max(...lngs),Math.max(...lats)], 'Drawn Polygon', 'poly'); 1355 deactivateDraw(); 1356}); 1357 1358/* âââ Country picker âââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1359function handleCountryHover(e) { 1360 const tooltip = document.getElementById('country-tooltip'); 1361 const feats = map.queryRenderedFeatures(e.point, { layers: ['countries-fill'] }); 1362 1363 if (!feats.length) { 1364 if (hoveredId != null) { 1365 if (hoveredId !== selectedId) 1366 map.setFeatureState({ source:'countries', id: hoveredId }, { hovered: false }); 1367 hoveredId = null; 1368 } 1369 tooltip.style.display = 'none'; 1370 return; 1371 } 1372 1373 const f = feats[0]; 1374 if (f.id == null) return; // defensive â clears the spam of "feature id required" errors 1375 if (f.id !== hoveredId) { 1376 if (hoveredId != null && hoveredId !== selectedId) 1377 map.setFeatureState({ source:'countries', id: hoveredId }, { hovered: false }); 1378 hoveredId = f.id; 1379 map.setFeatureState({ source:'countries', id: hoveredId }, { hovered: true }); 1380 } 1381 1382 const name = f.properties.name || ISO_NAMES[f.id] || `Region ${f.id}`; 1383 tooltip.textContent = name; 1384 tooltip.style.display = 'block'; 1385 tooltip.style.left = (e.originalEvent.clientX + 14) + 'px'; 1386 tooltip.style.top = (e.originalEvent.clientY - 32) + 'px'; 1387} 1388 1389function handleCountryClick(e) { 1390 const feats = map.queryRenderedFeatures(e.point, { layers: ['countries-fill'] }); 1391 if (!feats.length) return; 1392 1393 const f = feats[0]; 1394 const name = f.properties.name || ISO_NAMES[f.id] || `Region ${f.id}`;
1395 /* Look up the bbox from our own Map (MapLibre serializes feature 1396 properties through workers, so anything stashed on f.properties 1397 comes back as a string). Fallback to live computation if missing. */ 1398 const bbox = countryBbox.get(f.id) || bboxFromGeometry(f.geometry); 1399 if (!bbox) return; 1400 1401 /* persist the selected country's face colour */ 1402 if (selectedId !== null && selectedId !== f.id) 1403 map.setFeatureState({ source:'countries', id: selectedId }, { selected: false }); 1404 selectedId = f.id; 1405 map.setFeatureState({ source:'countries', id: selectedId }, { selected: true, hovered: false }); 1406 1407 /* draw a bbox rectangle (not the country polygon) around the country */ 1408 setAoiData(bboxToFC(bbox)); 1409 commitBbox(bbox, name, 'country'); 1410 map.fitBounds([[bbox[0],bbox[1]],[bbox[2],bbox[3]]], { padding:60, maxZoom:8, duration:700 }); 1411} 1412 1413/* True bbox that handles antimeridian-crossing geometries. After 1414 unwrapAntimeridian, a ring like Chukotka has lngs that go past +180; we 1415 compute the tight bbox there. The returned east longitude may exceed 1416 +180 â MapLibre handles that natively when drawing the rectangle. */ 1417function bboxFromGeometry(geom) { 1418 const coords = flatCoords(geom); 1419 if (!coords.length) return null; 1420 const lngs = coords.map(c => c[0]), lats = coords.map(c => c[1]); 1421 let minLng = Math.min(...lngs), maxLng = Math.max(...lngs); 1422 /* If the unwrap still gave us a >180° span (e.g. multi-part polygons 1423 where parts sit on opposite sides), shift the negative ones by +360 1424 and recompute â keep whichever span is tighter. */ 1425 if (maxLng - minLng > 180) { 1426 const shifted = lngs.map(l => l < 0 ? l + 360 : l); 1427 const sMin = Math.min(...shifted), sMax = Math.max(...shifted); 1428 if (sMax - sMin < maxLng - minLng) { minLng = sMin; maxLng = sMax; } 1429 } 1430 return [minLng, Math.min(...lats), maxLng, Math.max(...lats)]; 1431} 1432 1433/* Make each ring's longitudes continuous so antimeridian-crossing countries 1434 (Russia, Fiji) don't render a fill/stroke band across the whole map. 1435 Coordinates may end up outside [-180,180]; MapLibre wraps them cleanly. */ 1436function unwrapAntimeridian(geom) { 1437 if (!geom) return; 1438 const fixRing = (ring) => { 1439 for (let i = 1; i < ring.length; i++) { 1440 let d = ring[i][0] - ring[i - 1][0]; 1441 if (d > 180) ring[i][0] -= 360; 1442 else if (d < -180) ring[i][0] += 360; 1443 } 1444 }; 1445 if (geom.type === 'Polygon') geom.coordinates.forEach(fixRing); 1446 else if (geom.type === 'MultiPolygon') geom.coordinates.forEach(poly => poly.forEach(fixRing)); 1447} 1448 1449async function loadCountries() { 1450 if (countriesLoaded) return; 1451 showLoader('Loading countriesâ¦'); 1452 try { 1453 const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'); 1454 const topo = await res.json(); 1455 const geojson = topojson.feature(topo, topo.objects.countries); 1456 geojson.features.forEach(f => { 1457 /* numeric IDs â MapLibre feature-state silently ignores string IDs on 1458 GeoJSON sources, which is why hover/selection didn't render before */ 1459 f.id = Number(f.id); 1460 f.properties = f.properties || {}; 1461 if (!f.properties.name) f.properties.name = ISO_NAMES[f.id] || ''; 1462 unwrapAntimeridian(f.geometry); // fix Russia/Fiji fill bands across the map 1463 /* Cache bbox by id â we can't stash it on properties because MapLibre 1464 serializes feature properties through workers, turning arrays into 1465 strings and breaking the click handler. */ 1466 countryBbox.set(f.id, bboxFromGeometry(f.geometry)); 1467 }); 1468 /* Drop Antarctica (ISO 010): its polygon encircles the south pole and 1469 the simple walk-and-shift unwrap can't avoid leaving a stray edge 1470 across the bottom of the map. Country picking for Antarctica isn't 1471 a meaningful workflow anyway. */ 1472 geojson.features = geojson.features.filter(f => f.id !== 10); 1473 _countriesFC = geojson; 1474 map.getSource('countries').setData(geojson); 1475 1476 /* Fetch disputed-areas overlay (Natural Earth 50m). Includes Kashmir, 1477 Aksai Chin, Western Sahara, Crimea, Golan Heights, N. Cyprus, 1478 Somaliland, Abkhazia, etc. Failure here is non-fatal â country 1479 picking still works without the overlay. */ 1480 try { 1481 const dRes = await fetch('https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_admin_0_breakaway_disputed_areas.geojson'); 1482 const dGeo = await dRes.json();
1483 dGeo.features.forEach(f => unwrapAntimeridian(f.geometry)); 1484 _disputedFC = dGeo; 1485 map.getSource('disputed').setData(dGeo); 1486 } catch (_) { /* overlay is best-effort */ } 1487 1488 countriesLoaded = true; 1489 } catch (_) { 1490 showHint('Failed to load country data â check your connection', 4000); 1491 } 1492 hideLoader(); 1493} 1494 1495/* âââ Commit bbox & render panel âââââââââââââââââââââââââââââââââââââââââââ */ 1496function commitBbox(bbox, label, type) { 1497 currentBbox = bbox; 1498 coordLabel = label; 1499 coordType = type; 1500 /* Always draw the derived bbox rectangle. For type 'bbox' the AOI itself 1501 IS the bbox so the outline coincides with the AOI border â harmless; 1502 for poly / upload / country it visualises the snapped bounds. */ 1503 map.getSource('aoi-bbox')?.setData(bboxToFC(bbox)); 1504 renderPanel(); 1505 document.getElementById('coords-section').classList.add('visible'); 1506} 1507 1508function renderPanel() { 1509 if (!currentBbox) return; 1510 const p = precision; 1511 let [rawW, rawS, rawE, rawN] = currentBbox; 1512 /* If east > 180, the bbox wraps the antimeridian â display east in the 1513 -180..180 convention but flag it so the user understands. */ 1514 const wraps = rawE > 180; 1515 const w = +rawW.toFixed(p); 1516 const s = +rawS.toFixed(p); 1517 const e = +(wraps ? rawE - 360 : rawE).toFixed(p); 1518 const n = +rawN.toFixed(p); 1519 1520 /* source label + icon */ 1521 document.getElementById('coord-source-label').textContent = 1522 wraps ? `${coordLabel} ⤬ wraps antimeridian` : coordLabel; 1523 const iconEl = document.getElementById('coord-icon'); 1524 iconEl.innerHTML = coordType === 'country' 1525 ? '<circle cx="7" cy="7" r="6"/><path d="M1 7h12"/><path d="M7 1a10 10 0 010 12"/><path d="M7 1a10 10 0 000 12"/>' 1526 : coordType === 'poly' 1527 ? '<polygon points="7,1 13,5 11,12 3,12 1,5"/>' 1528 : '<rect x="1" y="1" width="12" height="12" rx="1.5"/>'; 1529 1530 const units = unitsForEpsg(4326); 1531 renderFormatList('format-list', buildFormats(w, s, e, n, units)); 1532 1533 /* transformed */ 1534 if (customEpsg && proj4.defs[`EPSG:${customEpsg}`]) { 1535 renderTransformed(w, s, e, n); 1536 } else { 1537 document.getElementById('transformed-section').classList.remove('visible'); 1538 if (typeof updateDownloadCrsTag === 'function') updateDownloadCrsTag(); 1539 } 1540} 1541 1542/* Build the list of copy formats in the user's chosen axis order. 1543 - Python list: respects the order toggle. 1544 - WKT: geometric vertex sequence, order toggle doesn't apply. */ 1545function buildFormats(w, s, e, n, units) { 1546 const orderLbl = order === 'wesn' ? 'W, E, S, N' : 'W, S, E, N'; 1547 const listVal = order === 'wesn' ? `[${w}, ${e}, ${s}, ${n}]` : `[${w}, ${s}, ${e}, ${n}]`; 1548 return [ 1549 { lbl: `Python list · ${orderLbl}${units ? ' ('+units+')' : ''}`, 1550 val: listVal }, 1551 { lbl: 'WKT BBox polygon', 1552 val: `POLYGON((${w} ${s}, ${e} ${s}, ${e} ${n}, ${w} ${n}, ${w} ${s}))` }, 1553 ]; 1554} 1555 1556/* Resolve display units for an EPSG code via the proj4 definition. 1557 Returns '°' for geographic CRS, 'm' for projected, 'ft' / 'us-ft' where 1558 defined, or '' if unknown. */ 1559function unitsForEpsg(code) { 1560 if (code === 4326 || code === 4269 || code === 4258) return '°'; 1561 const def = proj4.defs[`EPSG:${code}`]; 1562 if (!def) return ''; 1563 if (def.projName === 'longlat') return '°'; 1564 const u = def.units; 1565 if (!u) return 'm'; 1566 if (u === 'm' || u === 'meter' || u === 'metre') return 'm'; 1567 if (u === 'ft' || u === 'us-ft') return u; 1568 return u; 1569} 1570 1571function renderFormatList(containerId, formats) { 1572 const el = document.getElementById(containerId); 1573 el.innerHTML = ''; 1574 formats.forEach(f => el.appendChild(makeCard(f.lbl, f.val))); 1575} 1576 1577function renderTransformed(w, s, e, n) { 1578 const from = 'EPSG:4326', to = `EPSG:${customEpsg}`; 1579 try { 1580 const pts = [[w,s],[e,s],[e,n],[w,n]].map(p => proj4(from, to, p)); 1581 const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]); 1582 const p = precision; 1583 const tw = +Math.min(...xs).toFixed(p), ts = +Math.min(...ys).toFixed(p); 1584 const te = +Math.max(...xs).toFixed(p), tn = +Math.max(...ys).toFixed(p); 1585 1586 const txUnits = unitsForEpsg(customEpsg); 1587 document.getElementById('tx-chip').textContent = 1588 `EPSG:${customEpsg}${txUnits ? ' · ' + txUnits : ''}`; 1589 renderFormatList('format-list-tx', buildFormats(tw, ts, te, tn, txUnits)); 1590 document.getElementById('transformed-section').classList.add('visible'); 1591 if (typeof updateDownloadCrsTag === 'function') updateDownloadCrsTag(); 1592 } catch (_) { 1593 document.getElementById('transformed-section').classList.remove('visible'); 1594 } 1595} 1596 1597function makeCard(label, value) { 1598 const div = document.createElement('div'); 1599 div.className = 'format-card'; 1600 div.innerHTML = ` 1601 <div class="format-label">${label}</div> 1602 <div class="format-value-row"> 1603 <div class="format-value">${escHtml(value)}</div> 1604 <button class="copy-btn" title="Copy to clipboard"> 1605 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"> 1606 <rect x="5" y="5" width="8" height="9" rx="1"/> 1607 <path d="M3 11V3a1 1 0 011-1h7"/> 1608 </svg> 1609 Copy 1610 </button> 1611 </div>`; 1612 div.querySelector('.copy-btn').addEventListener('click', () => { 1613 copyText(value, div.querySelector('.copy-btn')); 1614 }); 1615 return div; 1616} 1617 1618/* âââ EPSG custom transform ââââââââââââââââââââââââââââââââââââââââââââââââ */ 1619let epsgTimer = null; 1620 1621document.getElementById('custom-epsg').addEventListener('input', (e) => { 1622 clearTimeout(epsgTimer); 1623 const val = e.target.value.trim(); 1624 const status = document.getElementById('epsg-status'); 1625 1626 if (!val) { 1627 customEpsg = null; 1628 status.textContent = ''; 1629 status.className = ''; 1630 document.getElementById('transformed-section').classList.remove('visible'); 1631 return; 1632 } 1633 1634 const code = parseInt(val); 1635 if (isNaN(code) || code < 1 || code > 999999) { 1636 status.textContent = 'Enter a valid numeric EPSG code'; 1637 status.className = 'err'; return; 1638 } 1639 if (code === 4326) { 1640 status.textContent = 'Source is already EPSG:4326'; 1641 status.className = 'err'; return; 1642 } 1643 1644 status.textContent = 'Looking upâ¦'; 1645 status.className = 'loading'; 1646 1647 epsgTimer = setTimeout(async () => { 1648 /* already known to proj4? */ 1649 if (proj4.defs[`EPSG:${code}`]) { 1650 customEpsg = code; 1651 status.textContent = `â EPSG:${code}`; 1652 status.className = 'ok'; 1653 if (currentBbox) renderPanel(); 1654 return; 1655 } 1656 /* fetch from epsg.io */ 1657 try { 1658 const res = await fetch(`https://epsg.io/${code}.proj4`); 1659 if (!res.ok) throw new Error(); 1660 const def = (await res.text()).trim(); 1661 if (!def || def.startsWith('<')) throw new Error(); 1662 proj4.defs(`EPSG:${code}`, def); 1663 customEpsg = code; 1664 status.textContent = `â EPSG:${code}`; 1665 status.className = 'ok'; 1666 if (currentBbox) renderPanel(); 1667 } catch (_) { 1668 customEpsg = null; 1669 status.textContent = `EPSG:${code} not found`; 1670 status.className = 'err'; 1671 document.getElementById('transformed-section').classList.remove('visible'); 1672 } 1673 }, 600); 1674}); 1675 1676/* âââ Precision slider âââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1677document.getElementById('precision-slider').addEventListener('input', (e) => { 1678 precision = parseInt(e.target.value); 1679 document.getElementById('precision-val').textContent = precision; 1680 if (currentBbox) renderPanel(); 1681}); 1682 1683/* Download current bbox as GeoJSON. Honours the CRS pill choice:
1684 - '4326' â standards-compliant GeoJSON (RFC 7946) 1685 - 'custom' â projected coords with a legacy `crs` member so GDAL/QGIS 1686 pick up the units. Some strict GeoJSON parsers will warn. */ 1687document.getElementById('btn-download-geojson').addEventListener('click', () => { 1688 if (!currentBbox) return; 1689 const useCustom = dlCrs === 'custom' && customEpsg && proj4.defs[`EPSG:${customEpsg}`]; 1690 1691 let [w, s, e, n] = currentBbox; 1692 if (e > 180) e = e - 360; 1693 const ringWGS84 = [[w,s],[e,s],[e,n],[w,n],[w,s]]; 1694 1695 let geojson, filenameSuffix; 1696 1697 if (useCustom) { 1698 const to = `EPSG:${customEpsg}`; 1699 const pts = [[w,s],[e,s],[e,n],[w,n]].map(p => proj4('EPSG:4326', to, p)); 1700 const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]); 1701 const tw = Math.min(...xs), ts = Math.min(...ys); 1702 const te = Math.max(...xs), tn = Math.max(...ys); 1703 const ring = [[tw,ts],[te,ts],[te,tn],[tw,tn],[tw,ts]]; 1704 1705 geojson = { 1706 type: 'FeatureCollection', 1707 /* Legacy CRS member â removed from RFC 7946 but still respected by 1708 GDAL/QGIS/Fiona for non-WGS84 GeoJSON. Strict parsers ignore it. */ 1709 crs: { type: 'name', properties: { name: `urn:ogc:def:crs:EPSG::${customEpsg}` } }, 1710 features: [{ 1711 type: 'Feature', 1712 bbox: [tw, ts, te, tn], 1713 properties: { 1714 name: coordLabel || 'bbox', 1715 source_type: coordType, 1716 epsg: customEpsg, 1717 bbox_wsen: [tw, ts, te, tn], 1718 units: unitsForEpsg(customEpsg) || 'm', 1719 }, 1720 geometry: { type: 'Polygon', coordinates: [ring] }, 1721 }], 1722 }; 1723 filenameSuffix = `_epsg${customEpsg}`; 1724 } else { 1725 geojson = { 1726 type: 'FeatureCollection', 1727 features: [{ 1728 type: 'Feature', 1729 bbox: [w, s, e, n], 1730 properties: { 1731 name: coordLabel || 'bbox', 1732 source_type: coordType, 1733 epsg: 4326, 1734 bbox_wsen: [w, s, e, n], 1735 units: '°', 1736 }, 1737 geometry: { type: 'Polygon', coordinates: [ringWGS84] }, 1738 }], 1739 }; 1740 filenameSuffix = ''; 1741 } 1742 1743 const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/geo+json' }); 1744 const url = URL.createObjectURL(blob); 1745 const safeName = (coordLabel || 'bbox').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'bbox'; 1746 1747 const a = document.createElement('a'); 1748 a.href = url; 1749 a.download = `bbox_${safeName}${filenameSuffix}.geojson`; 1750 document.body.appendChild(a); 1751 a.click(); 1752 document.body.removeChild(a); 1753 setTimeout(() => URL.revokeObjectURL(url), 1000); 1754 1755 const btn = document.getElementById('btn-download-geojson'); 1756 btn.classList.add('flash'); 1757 setTimeout(() => btn.classList.remove('flash'), 600); 1758}); 1759 1760/* CRS pill toggle for the download */ 1761document.querySelectorAll('.download-crs-pills .order-pill').forEach(btn => { 1762 btn.addEventListener('click', () => { 1763 if (btn.hidden) return; 1764 dlCrs = btn.dataset.dl; 1765 document.querySelectorAll('.download-crs-pills .order-pill').forEach(b => 1766 b.classList.toggle('active', b.dataset.dl === dlCrs)); 1767 updateDownloadCrsTag(); 1768 }); 1769}); 1770 1771/* Keep the EPSG tag + custom pill in sync with the source EPSG state */ 1772function updateDownloadCrsTag() { 1773 const tag = document.getElementById('dl-crs-tag'); 1774 const pill = document.getElementById('dl-pill-custom'); 1775 if (!tag || !pill) return; 1776 if (customEpsg && proj4.defs[`EPSG:${customEpsg}`]) { 1777 pill.hidden = false; 1778 pill.textContent = `EPSG:${customEpsg}`; 1779 } else { 1780 pill.hidden = true; 1781 if (dlCrs === 'custom') { 1782 dlCrs = '4326'; 1783 document.querySelectorAll('.download-crs-pills .order-pill').forEach(b => 1784 b.classList.toggle('active', b.dataset.dl === '4326')); 1785 } 1786 } 1787 tag.textContent = dlCrs === 'custom' ? `EPSG:${customEpsg}` : 'EPSG:4326'; 1788} 1789 1790document.querySelectorAll('.order-pill').forEach(btn => { 1791 btn.addEventListener('click', () => { 1792 order = btn.dataset.order; 1793 document.querySelectorAll('.order-pill').forEach(b => 1794 b.classList.toggle('active', b.dataset.order === order)); 1795 if (currentBbox) renderPanel(); 1796 }); 1797}); 1798 1799/* âââ Draw mode management âââââââââââââââââââââââââââââââââââââââââââââââââ */ 1800function setDrawMode(mode) { 1801 drawMode = mode; 1802 polyVerts = []; bboxAnchor = null; 1803 clearPreview(); 1804 1805 /* Only toolbar buttons live in the DOM now (sidebar Draw Tools removed). */ 1806 ['bbox','poly'].forEach(m => { 1807 const tb = document.getElementById(`tb-${m}`); 1808 if (tb) tb.classList.toggle('active', m === mode); 1809 }); 1810 1811 if (mode) { 1812 document.body.classList.add('mode-draw'); 1813 map.dragPan.disable(); 1814 showHint(mode === 'bbox' 1815 ? 'Click and drag to draw a bounding box · Esc to cancel' 1816 : 'Click to add vertices · Double-click to close · Esc to cancel'); 1817 } else { 1818 document.body.classList.remove('mode-draw'); 1819 map.dragPan.enable(); 1820 hideHint(); 1821 } 1822} 1823 1824function deactivateDraw() { setDrawMode(null); } 1825 1826function setCountryMode(on) { 1827 countryMode = on; 1828 document.getElementById('tb-country').classList.toggle('country-on', on); 1829 1830 if (on) { 1831 document.body.classList.add('mode-country'); 1832 setDrawMode(null); 1833 setCountriesVisible(true); 1834 showHint('Hover a country · Click to snap a bbox around it'); 1835 loadCountries(); 1836 } else { 1837 document.body.classList.remove('mode-country'); 1838 setCountriesVisible(false); 1839 document.getElementById('country-tooltip').style.display = 'none'; 1840 if (hoveredId !== null) { 1841 map.setFeatureState({ source:'countries', id: hoveredId }, { hovered: false }); 1842 hoveredId = null; 1843 } 1844 hideHint(); 1845 } 1846} 1847 1848function setCountriesVisible(on) { 1849 const v = on ? 'visible' : 'none'; 1850 ['countries-fill','countries-line','disputed-fill','disputed-line'].forEach(id => { 1851 if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', v); 1852 }); 1853} 1854 1855/* âââ Clear all âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1856function clearAll() { 1857 currentBbox = null; coordLabel = ''; 1858 polyVerts = []; bboxAnchor = null; 1859 deactivateDraw(); 1860 if (selectedId !== null) { 1861 map.setFeatureState({ source:'countries', id: selectedId }, { selected: false, hovered: false }); 1862 selectedId = null; 1863 } 1864 setAoiData(emptyFC()); 1865 map.getSource('aoi-bbox')?.setData(emptyFC()); 1866 clearPreview(); 1867 document.getElementById('coords-section').classList.remove('visible'); 1868 document.getElementById('transformed-section').classList.remove('visible'); 1869 document.getElementById('format-list').innerHTML = ''; 1870 document.getElementById('format-list-tx').innerHTML = ''; 1871} 1872 1873/* âââ Upload GeoJSON âââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1874function handleUpload(file) { 1875 if (!file) return; 1876 const reader = new FileReader(); 1877 reader.onload = (ev) => { 1878 try { 1879 const raw = JSON.parse(ev.target.result); 1880 const coords = []; 1881 function dig(c) { if (typeof c[0] === 'number') coords.push(c);
1881 else c.forEach(dig); } 1882 function walk(obj) { 1883 if (!obj) return; 1884 if (obj.type === 'FeatureCollection') obj.features.forEach(f => walk(f)); 1885 else if (obj.type === 'Feature') walk(obj.geometry); 1886 else if (obj.coordinates) dig(obj.coordinates); 1887 } 1888 walk(raw); 1889 if (!coords.length) { showHint('No geometry found in file', 3000); return; } 1890 1891 const lngs = coords.map(c => c[0]), lats = coords.map(c => c[1]); 1892 const bbox = [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)]; 1893 1894 let fc; 1895 if (raw.type === 'FeatureCollection') fc = raw; 1896 else if (raw.type === 'Feature') fc = { type:'FeatureCollection', features:[raw] }; 1897 else fc = { type:'FeatureCollection', features:[{ type:'Feature', geometry:raw, properties:{} }] }; 1898 1899 setAoiData(fc); 1900 commitBbox(bbox, file.name.replace(/\.(geo)?json$/i,''), 'upload'); 1901 map.fitBounds([[bbox[0],bbox[1]],[bbox[2],bbox[3]]], { padding:60, maxZoom:12, duration:700 }); 1902 } catch (_) { showHint('Invalid GeoJSON file', 3000); } 1903 }; 1904 reader.readAsText(file); 1905} 1906 1907/* âââ Button wiring ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1908function toggleDraw(mode) { 1909 setCountryMode(false); 1910 setDrawMode(drawMode === mode ? null : mode); 1911} 1912 1913/* Draw / upload / clear are driven by the floating toolbar only; the 1914 sidebar duplicates were removed for being redundant. */ 1915document.getElementById('tb-bbox').addEventListener('click', () => toggleDraw('bbox')); 1916document.getElementById('tb-poly').addEventListener('click', () => toggleDraw('poly')); 1917document.getElementById('tb-clear').addEventListener('click', clearAll); 1918document.getElementById('tb-upload').addEventListener('change', (e) => { handleUpload(e.target.files[0]); e.target.value=''; }); 1919 1920document.getElementById('tb-country').addEventListener('click', () => setCountryMode(!countryMode)); 1921 1922document.getElementById('sidebar-toggle').addEventListener('click', () => document.body.classList.toggle('sidebar-collapsed')); 1923 1924/* Escape key cancels draw mode / country mode */ 1925document.addEventListener('keydown', (e) => { 1926 if (e.key === 'Escape') { 1927 if (drawMode) { deactivateDraw(); return; } 1928 if (countryMode) { setCountryMode(false); } 1929 } 1930}); 1931 1932/* âââ Theme switch âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1933const STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json'; 1934const STYLE_PAPER = 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json'; 1935const _CUSTOM_SRCS = ['countries','disputed','graticule-lines','aoi','aoi-bbox','preview']; 1936const _CUSTOM_LYRS = ['countries-fill','countries-line','disputed-fill','disputed-line', 1937 'graticule-lines','aoi-fill','aoi-line','aoi-bbox-line','preview-fill','preview-line']; 1938 1939function setTheme(t) { 1940 document.documentElement.setAttribute('data-theme', t); 1941 document.querySelectorAll('[data-theme-btn]').forEach(b => 1942 b.setAttribute('aria-pressed', String(b.dataset.themeBtn === t))); 1943 localStorage.setItem('bboxer_theme', t); 1944 1945 map.setStyle(t === 'paper' ? STYLE_PAPER : STYLE_DARK, { 1946 transformStyle: (prev, next) => { 1947 const srcs = {}; 1948 _CUSTOM_SRCS.forEach(id => { if (prev.sources[id]) srcs[id] = prev.sources[id]; }); 1949 const lyrs = (prev.layers || []).filter(l => _CUSTOM_LYRS.includes(l.id)); 1950 return { ...next, sources: { ...next.sources, ...srcs }, layers: [...next.layers, ...lyrs] }; 1951 } 1952 }); 1953 1954 map.once('style.load', () => { 1955 if (_currentAoiFC) map.getSource('aoi')?.setData(_currentAoiFC); 1956 if (currentBbox) map.getSource('aoi-bbox')?.setData(bboxToFC(currentBbox)); 1957 if (_countriesFC) map.getSource('countries')?.setData(_countriesFC); 1958 if (_disputedFC) map.getSource('disputed')?.setData(_disputedFC); 1959 if (countryMode) setCountriesVisible(true); 1960 if (selectedId !== null) 1961 map.setFeatureState({ source: 'countries', id: selectedId }, { selected: true }); 1962 regenerateGraticule(); 1963 }); 1964} 1965 1966document.querySelectorAll('[data-theme-btn]').forEach(b => 1967 b.addEventListener('click', () => setTheme(b.dataset.themeBtn))); 1968 1969(function () { 1970 const saved = localStorage.getItem('bboxer_theme'); 1971 if (saved === 'paper') setTheme('paper'); 1972}()); 1973 1974/* âââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1975function emptyFC() { return { type:'FeatureCollection', features:[] }; } 1976 1977function setAoiData(fc) { _currentAoiFC = fc; map.getSource('aoi')?.setData(fc); } 1978 1979function toBbox(lng1, lat1, lng2, lat2) { 1980 return [Math.min(lng1,lng2), Math.min(lat1,lat2), Math.max(lng1,lng2), Math.max(lat1,lat2)]; 1981} 1982 1983function bboxToFC([w,s,e,n]) { 1984 return { type:'FeatureCollection', features:[{ 1985 type:'Feature', 1986 geometry:{ type:'Polygon', coordinates:[[[w,s],[e,s],[e,n],[w,n],[w,s]]] }, 1987 properties:{} 1988 }]}; 1989} 1990 1991function setPreview(fc) { map.getSource('preview')?.setData(fc); } 1992function clearPreview() { map.getSource('preview')?.setData(emptyFC()); } 1993 1994function flatCoords(geom) { 1995 const out = []; 1996 function dig(c) { if (typeof c[0] === 'number') out.push(c);
1996 else c.forEach(dig); } 1997 dig(geom.coordinates); 1998 return out; 1999} 2000 2001function copyText(text, btn) { 2002 navigator.clipboard.writeText(text).then(() => { 2003 btn.classList.add('copied'); 2004 const orig = btn.innerHTML; 2005 btn.innerHTML = 'â Copied'; 2006 setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = orig; }, 1800); 2007 }); 2008} 2009 2010function escHtml(s) { 2011 return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 2012} 2013 2014let hintTimer = null; 2015function showHint(msg, auto) { 2016 const el = document.getElementById('hint-banner'); 2017 el.textContent = msg; 2018 el.classList.add('visible'); 2019 clearTimeout(hintTimer); 2020 if (auto) hintTimer = setTimeout(hideHint, auto); 2021} 2022function hideHint() { document.getElementById('hint-banner').classList.remove('visible'); } 2023 2024function showLoader(msg) { 2025 document.getElementById('loader-text').textContent = msg; 2026 document.getElementById('loader').classList.add('visible'); 2027} 2028function hideLoader() { document.getElementById('loader').classList.remove('visible'); } 2029 2030/* Called by the tour to demo the Country Picker step */ 2031function tourSelectDemoCountry() { 2032 var france = [-5.1, 42.3, 8.2, 51.1]; 2033 setAoiData(bboxToFC(france)); 2034 commitBbox(france, 'France', 'country'); 2035 map.fitBounds([[france[0], france[1]], [france[2], france[3]]], { padding: 60, duration: 600 }); 2036} 2037 2038/* Called by the tour when it needs to demo the Coordinates panel */ 2039function tourDrawDemoBbox() { 2040 var demo = [-10, 36, 42, 65]; /* Western Europe â fallback if no country was picked */ 2041 setAoiData(bboxToFC(demo)); 2042 commitBbox(demo, 'Demo â Europe', 'bbox'); 2043 map.fitBounds([[demo[0], demo[1]], [demo[2], demo[3]]], { padding: 60, duration: 600 }); 2044} 2045</script>
2045 2046 2047<!-- ââââââââââââââââââââââââââ TOUR ââââââââââââââââââââââââââââââââââââââââââ --> 2048 2049<!-- Welcome modal --> 2050<div id="tour-welcome"> 2051 <div class="tw-card"> 2052 <div class="tw-hd"> 2053 <div class="tw-mark"><img src="logo.svg" alt="BBoxer"></div> 2054 <div> 2055 <div class="tw-wordmark">B<span class="tw-sep">·</span>Boxer</div> 2056 <div class="tw-sub">Bounding-box discovery console</div> 2057 </div> 2058 </div> 2059 <div class="tw-bd"> 2060 <div class="tw-mod-h"> 2061 <span class="ix">00</span><span>Welcome</span><span class="rule"></span> 2062 </div> 2063 <div class="tw-desc"> 2064 <strong>Draw, snap & copy</strong> bounding-box coordinates in any 2065 CRS â right in the browser, no sign-up needed.<br><br> 2066 Want a quick tour of the interface? 2067 </div> 2068 <div class="tw-actions"> 2069 <button id="btn-tour-start">â Take the Tour</button> 2070 <button id="btn-tour-skip-intro">Skip · I know what I'm doing</button> 2071 </div> 2072 </div> 2073 </div> 2074</div> 2075 2076<!-- Step overlay, spotlight and card --> 2077<div id="tour-overlay"></div> 2078<div id="tour-spot"></div> 2079<div id="tour-card"> 2080 <div class="tc-head"> 2081 <span class="tc-ix" id="tc-ix">01</span> 2082 <span>·</span> 2083 <span class="tc-lbl" id="tc-step-lbl">TOOLBAR</span> 2084 <span class="rule"></span> 2085 <button id="btn-tour-exit">â EXIT</button> 2086 </div> 2087 <div class="tc-bw"> 2088 <div class="tc-title" id="tc-title"></div> 2089 <div class="tc-body" id="tc-body"></div> 2090 </div> 2091 <div class="tc-foot"> 2092 <button class="tc-btn" id="btn-tour-prev" disabled>â BACK</button> 2093 <div class="tc-dots" id="tc-dots"></div> 2094 <button class="tc-btn tc-next" id="btn-tour-next">NEXT â</button> 2095 </div> 2096</div> 2097
2098<script> 2099/* âââ Tour âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2100(function () { 2101 'use strict'; 2102 const STORAGE_KEY = 'bboxer_tour_v1'; 2103 2104 const STEPS = [ 2105 { 2106 target: '#aoi-toolbar', 2107 short: 'TOOLBAR', 2108 title: 'Drawing Toolbar', 2109 body: 'This floating toolbar gives you four ways to define your Area of Interest. Let\'s walk through each tool.', 2110 pos: 'left', 2111 }, 2112 { 2113 target: '#tb-bbox', 2114 short: 'BBOX DRAW', 2115 title: 'Draw Bounding Box', 2116 body: 'Click this button, then click-drag on the map to draw a rectangle. Release to confirm. Press Esc to cancel at any time.', 2117 pos: 'left', 2118 }, 2119 { 2120 target: '#tb-poly', 2121 short: 'POLYGON', 2122 title: 'Draw Polygon', 2123 body: 'Click to place polygon vertices one by one on the map. Double-click to close the shape. BBoxer computes the minimum bounding rectangle around it.', 2124 pos: 'left', 2125 }, 2126 { 2127 targetFn: function () { return document.querySelector('#aoi-toolbar label.tb-btn'); }, 2128 short: 'UPLOAD', 2129 title: 'Upload GeoJSON', 2130 body: 'Click to load a .geojson or .json file from your computer. BBoxer automatically extracts the bounding box of any valid GeoJSON geometry.', 2131 pos: 'left', 2132 }, 2133 { 2134 target: '#tb-country', 2135 short: 'COUNTRIES', 2136 title: 'Country Picker', 2137 body: 'Toggles country mode on the map. Hover over any country to highlight it, then click to snap the bbox to that country\'s full extent â like France here!', 2138 pos: 'left', 2139 onEnter: function () { 2140 if (!document.getElementById('coords-section').classList.contains('visible')) { 2141 tourDemoDrawn = true; 2142 if (typeof tourSelectDemoCountry === 'function') tourSelectDemoCountry(); 2143 } 2144 }, 2145 }, 2146 { 2147 target: '#tb-clear', 2148 short: 'CLEAR', 2149 title: 'Clear All', 2150 body: 'Removes all drawn shapes and resets the coordinates panel so you can start fresh.', 2151 pos: 'left', 2152 }, 2153 { 2154 targetFn: function () { return document.querySelectorAll('#sidebar .panel-section')[0]; }, 2155 short: 'CRS', 2156 title: 'Coordinate Reference System', 2157 body: 'BBoxer always works in WGS84 (EPSG:4326) by default. Type any EPSG code â e.g. 32633 for UTM Zone 33N â to also receive coordinates projected into that CRS.', 2158 pos: 'right', 2159 }, 2160 { 2161 targetFn: function () { 2162 var el = document.getElementById('coords-section'); 2163 return (el && el.classList.contains('visible')) ? el : null; 2164 }, 2165 short: 'COORDS', 2166 onEnter: function () { 2167 if (!document.getElementById('coords-section').classList.contains('visible')) { 2168 tourDemoDrawn = true; 2169 if (typeof tourDrawDemoBbox === 'function') tourDrawDemoBbox(); 2170 } 2171 }, 2172 title: 'Coordinates Panel', 2173 body: 'Your bbox coordinates are live here in two formats: a Python list (W, S, E, N or W, E, S, N) and a WKT BBox polygon. Use the precision slider and axis-order toggle to match your tool.', 2174 pos: 'right', 2175 }, 2176 { 2177 target: '#sidebar-toggle', 2178 short: 'SIDEBAR', 2179 title: 'Sidebar Toggle', 2180 body: 'Need more map space? Click this tab to collapse or expand the sidebar at any time.', 2181 pos: 'right', 2182 }, 2183 ]; 2184 2185 var currentStep = 0; 2186 var tourActive = false; 2187 var tourDemoDrawn = false; 2188 2189 var elWelcome = document.getElementById('tour-welcome'); 2190 var elOverlay = document.getElementById('tour-overlay'); 2191 var elSpot = document.getElementById('tour-spot'); 2192 var elCard = document.getElementById('tour-card'); 2193 var elStepIx = document.getElementById('tc-ix'); 2194 var elStepLbl = document.getElementById('tc-step-lbl'); 2195 var elTitle = document.getElementById('tc-title'); 2196 var elBody = document.getElementById('tc-body'); 2197 var elDots = document.getElementById('tc-dots'); 2198 var btnPrev = document.getElementById('btn-tour-prev'); 2199 var btnNext = document.getElementById('btn-tour-next'); 2200 var btnExit = document.getElementById('btn-tour-exit'); 2201 var btnStart = document.getElementById('btn-tour-start'); 2202 var btnSkip = document.getElementById('btn-tour-skip-intro'); 2203 var btnHelp = document.getElementById('tour-help'); 2204 2205 /* build progress dots */ 2206 function buildDots() { 2207 elDots.innerHTML = '';
2208 STEPS.forEach(function () { 2209 var d = document.createElement('div'); 2210 d.className = 'tc-dot'; 2211 elDots.appendChild(d); 2212 }); 2213 } 2214 2215 function updateDots() { 2216 var dots = elDots.querySelectorAll('.tc-dot'); 2217 dots.forEach(function (d, i) { d.classList.toggle('active', i === currentStep); }); 2218 } 2219 2220 /* resolve target element for a step */ 2221 function getTarget(step) { 2222 if (step.targetFn) return step.targetFn(); 2223 if (step.target) return document.querySelector(step.target); 2224 return null; 2225 } 2226 2227 /* position spotlight over element */ 2228 function placeSpot(el) { 2229 if (!el) { 2230 elSpot.classList.remove('active'); 2231 return; 2232 } 2233 var PAD = 6; 2234 var r = el.getBoundingClientRect(); 2235 elSpot.style.left = (r.left - PAD) + 'px'; 2236 elSpot.style.top = (r.top - PAD) + 'px'; 2237 elSpot.style.width = (r.width + PAD * 2) + 'px'; 2238 elSpot.style.height = (r.height + PAD * 2) + 'px'; 2239 elSpot.classList.add('active'); 2240 } 2241 2242 /* position tour card relative to target element */ 2243 function placeCard(el, pos, center) { 2244 elCard.style.transform = ''; 2245 elCard.classList.remove('tc-center'); 2246 2247 if (center || !el) { 2248 elCard.classList.add('tc-center'); 2249 return; 2250 } 2251 2252 var MARGIN = 14; 2253 var CW = 304; 2254 var r = el.getBoundingClientRect(); 2255 var vw = window.innerWidth; 2256 var vh = window.innerHeight; 2257 var ch = elCard.offsetHeight || 200; 2258 var left, top; 2259 2260 if (pos === 'right') { 2261 left = r.right + MARGIN; 2262 if (left + CW > vw - 8) left = r.left - CW - MARGIN; 2263 } else { 2264 left = r.left - CW - MARGIN; 2265 if (left < 8) left = r.right + MARGIN; 2266 } 2267 2268 top = r.top + r.height / 2 - ch / 2; 2269 left = Math.max(8, Math.min(left, vw - CW - 8)); 2270 top = Math.max(8, Math.min(top, vh - ch - 8)); 2271 2272 elCard.style.left = left + 'px'; 2273 elCard.style.top = top + 'px'; 2274 } 2275 2276 /* render a step */ 2277 function showStep(idx) { 2278 var step = STEPS[idx]; 2279 var total = STEPS.length; 2280 2281 elStepIx.textContent = String(idx + 1).padStart(2, '0'); 2282 elStepLbl.textContent = step.short || step.title.toUpperCase().slice(0, 12); 2283 elTitle.textContent = step.title; 2284 elBody.textContent = step.body; 2285 2286 btnPrev.disabled = (idx === 0); 2287 var isLast = (idx === total - 1); 2288 btnNext.textContent = isLast ? 'DONE â' : 'NEXT â'; 2289 2290 elCard.classList.add('active'); 2291 updateDots(); 2292 2293 if (step.onEnter) { 2294 step.onEnter(); 2295 /* Re-resolve target after onEnter may have mutated the DOM */ 2296 setTimeout(function () { 2297 var target = getTarget(step); 2298 elOverlay.classList.toggle('dark-bg', !target); 2299 placeSpot(target); 2300 placeCard(target, step.pos, !target); 2301 }, 200); 2302 } else { 2303 var target = getTarget(step); 2304 elOverlay.classList.toggle('dark-bg', !target); 2305 placeSpot(target); 2306 requestAnimationFrame(function () { placeCard(target, step.pos, !target); }); 2307 } 2308 } 2309 2310 function goTo(idx) { 2311 currentStep = Math.max(0, Math.min(idx, STEPS.length - 1)); 2312 showStep(currentStep); 2313 } 2314 2315 function startTour() { 2316 tourActive = true; 2317 currentStep = 0; 2318 elOverlay.classList.add('active'); 2319 elCard.classList.add('active'); 2320 btnHelp.classList.add('th-hidden'); 2321 buildDots(); 2322 showStep(0); 2323 } 2324 2325 function endTour() { 2326 tourActive = false; 2327 elOverlay.classList.remove('active', 'dark-bg'); 2328 elSpot.classList.remove('active'); 2329 elCard.classList.remove('active', 'tc-center'); 2330 btnHelp.classList.remove('th-hidden'); 2331 localStorage.setItem(STORAGE_KEY, '1'); 2332 if (tourDemoDrawn) { 2333 tourDemoDrawn = false; 2334 if (typeof clearAll === 'function') clearAll(); 2335 } 2336 } 2337 2338 function showWelcome() { 2339 elWelcome.classList.add('active'); 2340 btnHelp.classList.add('th-hidden'); 2341 } 2342 2343 function hideWelcome() { 2344 elWelcome.classList.remove('active'); 2345 } 2346 2347 /* reposition on window resize */ 2348 window.addEventListener('resize', function () { 2349 if (!tourActive) return; 2350 var step = STEPS[currentStep]; 2351 var target = getTarget(step); 2352 var useCenter = !target; 2353 placeSpot(target); 2354 placeCard(target, step.pos, useCenter); 2355 }); 2356 2357 /* wire buttons */ 2358 btnStart.addEventListener('click', function () { hideWelcome(); startTour(); }); 2359 btnSkip.addEventListener('click', function () { 2360 hideWelcome(); 2361 btnHelp.classList.remove('th-hidden'); 2362 localStorage.setItem(STORAGE_KEY, '1'); 2363 }); 2364 btnPrev.addEventListener('click', function () { goTo(currentStep - 1); }); 2365 btnNext.addEventListener('click', function () { 2366 if (currentStep === STEPS.length - 1) { endTour(); } 2367 else { goTo(currentStep + 1); } 2368 }); 2369 btnExit.addEventListener('click', endTour); 2370 btnHelp.addEventListener('click', showWelcome); 2371 2372 /* Escape closes tour */ 2373 document.addEventListener('keydown', function (e) { 2374 if (e.key === 'Escape' && tourActive) endTour(); 2375 }); 2376 2377 /* auto-show on first visit */ 2378 if (!localStorage.getItem(STORAGE_KEY)) { 2379 setTimeout(showWelcome, 900); 2380 } else { 2381 btnHelp.classList.remove('th-hidden'); 2382 } 2383}()); 2384</script>
vendor: 1 bytes, line 2384
2384
2385<script> 2386if ('serviceWorker' in navigator) { 2387 window.addEventListener('load', () => 2388 navigator.serviceWorker.register('/bboxer/sw.js') 2389 ); 2390} 2391</script>
2391 2392</body> 2393</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.