PageSourceSearch

https://www.pmuguda.com/bboxer/

html pmuguda.com collected 2026-10-09 22:08:35 UTC 100,697 bytes, 2,393 lines download raw bytes

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 &amp; 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,&nbsp;S,&nbsp;E,&nbsp;N</button>
831        <button class="order-pill"        data-order="wesn" title="minX, maxX, minY, maxY — matplotlib extent / xarray slicing">W,&nbsp;E,&nbsp;S,&nbsp;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 &amp; 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">→&nbsp;&nbsp;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.