1document.addEventListener("DOMContentLoaded", () => { 2 const sondeTypes = [ 3 { id: "dfm17", label: "DFM17", full: "Graw DFM17", image: "img/dfm17.jpg" }, 4 { id: "rs41", label: "RS41", full: "Vaisala RS41", image: "img/rs41sge.jpg" }, 5 { id: "m20", label: "M20", full: "Meteomodem M20", image: "img/m20.jpg" }, 6 { id: "m10", label: "M10", full: "Meteomodem M10", image: "img/m10.jpg" }, 7 { id: "imet4", label: "iMet-4", full: "InterMet iMet-4", image: "img/imet4.jpg" }, 8 { id: "ims100", label: "iMS-100", full: "Meisei iMS-100", image: "img/ims100.jpg" }, 9 { id: "imet54", label: "iMet-54", full: "InterMet iMet-54", image: "img/imet54.jpg" }, 10 { id: "wxr301", label: "WxR-301", full: "Weathex WxR-301", image: "img/wxr301.jpg" }, 11 { id: "dfm09", label: "DFM09", full: "Graw DFM09", image: "img/dfm09.jpg"}, 12 { id: "rs92", label: "RS92", full: "Vaisala RS92", image: "img/rs92.jpg" }, 13 { id: "lms6", label: "LMS6", full: "Lockheed Martin LMS6", image: "img/lms6.jpg" }, 14 { id: "mrz", label: "MRZ", full: "MeteoRadiy MRZ" }, 15 { id: "other", label: "Other", full: "Other radiosonde" } 16 ]; 17 18 const typeGrid = document.getElementById("typeGrid"); 19 const steps = { 20 2: document.getElementById("step2"), 21 3: document.getElementById("step3"), 22 4: document.getElementById("step4") 23 }; 24 const step1 = document.getElementById("step1"); 25 const serialInput = document.getElementById("serialInput"); 26 const lookupBtn = document.getElementById("lookupBtn"); 27 const lookupStatus = document.getElementById("lookupStatus"); 28 const recoveryInfo = document.getElementById("recoveryInfo"); 29 const serialHint = document.getElementById("serialHint"); 30 const imetNotice = document.getElementById("imetNotice"); 31 const latInput = document.getElementById("latInput"); 32 const lonInput = document.getElementById("lonInput"); 33 const altInput = document.getElementById("altInput"); 34 const useLocationBtn = document.getElementById("useLocationBtn"); 35 const locationErrors = document.getElementById("locationErrors"); 36 const locationStatus = document.getElementById("locationStatus"); 37 const recoveryForm = document.getElementById("recoveryForm"); 38 const plannedWrapper = document.getElementById("plannedWrapper"); 39 const submitStatus = document.getElementById("submitStatus"); 40 const defaultSerialPlaceholder = "e.g. N1234567"; 41 const submitBtn = document.getElementById("submitBtn"); 42 43 let selectedType = null; 44 let suppressSerialHint = false; 45 let map; 46 let marker; 47 let predictionMarker; 48 let sondeMarker; 49 let predictionPolyline; 50 let lastSondePosition = null; 51 let lastPredictionPosition = null; 52 let recentSondesBySerial = null; 53 let recentSondesPromise = null; 54
55 function renderSondeTypes() { 56 typeGrid.innerHTML = ""; 57 sondeTypes.forEach((type) => { 58 const card = document.createElement("div"); 59 card.className = "sonde-card"; 60 card.title = type.full; 61 card.dataset.id = type.id; 62 63 if (type.image) { 64 const img = document.createElement("img"); 65 img.src = type.image; 66 img.alt = type.full; 67 card.appendChild(img); 68 } else { 69 const placeholder = document.createElement("div"); 70 placeholder.className = "no-image"; 71 placeholder.textContent = type.label; 72 card.appendChild(placeholder); 73 } 74 75 const label = document.createElement("div"); 76 label.className = "label"; 77 label.textContent = type.label; 78 card.appendChild(label); 79 80 card.addEventListener("click", (e) => { 81 e.stopPropagation(); 82 selectType(type, card); 83 }); 84 typeGrid.appendChild(card); 85 }); 86 } 87 88 async function fetchRecoveryReports(serial) { 89 if (!recoveryInfo) return false; 90 recoveryInfo.classList.add("hidden"); 91 recoveryInfo.textContent = "Checking for existing recovery reportsâ¦"; 92 try { 93 const response = await fetch(`https://api.v2.sondehub.org/recovered?serial=${encodeURIComponent(serial)}`); 94 if (!response.ok) throw new Error("No existing recovery reports found."); 95 const data = await response.json(); 96 if (!data || (Array.isArray(data) && data.length === 0)) { 97 //recoveryInfo.textContent = "No existing recovery reports found."; 98 //recoveryInfo.classList.remove("hidden"); 99 return false; 100 } 101 const records = Array.isArray(data) ? data : [data]; 102 const lines = records.map((rec, idx) => { 103 const status = rec.recovered ? "Recovered" : rec.planned ? "Planned" : "Not Recovered"; 104 const who = rec.recovered_by || "Unknown"; 105 const desc = rec.description ? `Notes: ${rec.description}` : "No notes."; 106 const coords = Number.isFinite(rec.lat) && Number.isFinite(rec.lon) ? ` @ ${rec.lat.toFixed(5)}, ${rec.lon.toFixed(5)}` : ""; 107 const when = formatTimestamp(rec.time || rec.datetime || rec.created_at); 108 const whenStr = when ? ` on ${when}` : ""; 109 return `${idx + 1}. ${status} by ${who}${whenStr}${coords}. ${desc}`; 110 }); 111 recoveryInfo.textContent = `Existing recovery reports (${records.length}):\n${lines.join("\n")}`; 112 recoveryInfo.classList.remove("hidden"); 113 return true; 114 } catch (err) { 115 //recoveryInfo.textContent = err.message || "No existing recovery reports found."; 116 //recoveryInfo.classList.remove("hidden"); 117 return false; 118 } 119 } 120 121 function selectType(type, card) { 122 selectedType = type; 123 serialInput.value = ""; 124 serialInput.classList.remove("input-error"); 125 applySerialInputFormat(type.id); 126 serialInput.focus(); 127 updateSerialHint(type); 128 lookupStatus.textContent = "Enter the serial number printed on this radiosonde."; 129 lookupStatus.className = "status-line"; 130 if (recoveryInfo) { 131 recoveryInfo.classList.add("hidden"); 132 recoveryInfo.textContent = ""; 133 } 134 Object.values(steps).forEach((step) => step.classList.add("hidden")); 135 steps[2].classList.remove("hidden"); 136 typeGrid.querySelectorAll(".sonde-card").forEach((node) => node.classList.remove("selected")); 137 card.classList.add("selected");
138 //serialHint.textContent = `Look for the serial on the label or casing of a ${type.full}. A diagram will appear here once available.`; 139 //scrollIntoView(steps[2]); 140 scrollIntoView(document.getElementById("serialInput")); 141 if (step1) { 142 step1.classList.add("collapsed"); 143 } 144 } 145 146 function showStep(stepNumber) { 147 if (steps[stepNumber]) { 148 steps[stepNumber].classList.remove("hidden"); 149 if (stepNumber === 3 && map) { 150 setTimeout(() => map.invalidateSize(), 150); 151 } 152 } 153 } 154 155 function validateSerial(serial) { 156 if (!serial) return { valid: false, message: "Please enter a serial number before looking it up." }; 157 if (selectedType?.id === "rs41" || selectedType?.id === "rs92") { 158 const rs41Pattern = /^[A-Za-z][0-9]{7}$/; 159 if (!rs41Pattern.test(serial)) { 160 return { valid: false, message: "RS41/RS92 serials are one letter followed by 7 numbers (e.g. N1234567)." }; 161 } 162 } 163 if (selectedType?.id === "dfm17") { 164 const dfmPattern = /^[0-9]{5}-[0-9]{6}$/; 165 if (!dfmPattern.test(serial)) { 166 return { valid: false, message: "DFM17 serials look like AABBB-CCCCCC (dash included)." }; 167 } 168 } 169 if (selectedType?.id === "dfm09") { 170 const dfm09Pattern = /^[0-9]{6}$/; 171 if (!dfm09Pattern.test(serial)) { 172 return { valid: false, message: "DFM09 serials must be exactly 6 digits." }; 173 } 174 } 175 if (selectedType?.id === "m10" || selectedType?.id === "m20") { 176 const m10Pattern = /^[A-Za-z0-9]{3}-[A-Za-z0-9]-[A-Za-z0-9]{5}$/; 177 if (!m10Pattern.test(serial)) { 178 return { valid: false, message: "M10/M20 serials look like AAA-B-CCCCC (dash included)." }; 179 } 180 } 181 if (selectedType?.id === "imet4") { 182 const imetPattern = /^[A-Z0-9]{8}$/; 183 if (!imetPattern.test(serial)) { 184 return { valid: false, message: "iMet-4 serials must be 8 characters (numbers or uppercase letters)." }; 185 } 186 } 187 return { valid: true, message: "" }; 188 } 189 190 async function lookupSerial() { 191 const serial = serialInput.value.trim(); 192 const validation = validateSerial(serial); 193 if (!validation.valid) { 194 lookupStatus.textContent = validation.message; 195 lookupStatus.className = "status-line error-text"; 196 serialInput.classList.add("input-error"); 197 return; 198 } 199 serialInput.classList.remove("input-error"); 200 const lookupSerialValue = normalizeSerial(serial); 201
202 lookupStatus.textContent = `Looking up ${lookupSerialValue} in SondeHub⦠(This can take a while)`; 203 lookupStatus.className = "status-line loading"; 204 205 let hasExistingRecovery = false; 206 lastSondePosition = null; 207 lastPredictionPosition = null; 208 try { 209 const recentPosition = await fetchRecentSondePosition(lookupSerialValue); 210 if (recentPosition) { 211 applySondePosition(recentPosition, "Last observed sonde position loaded from the recent SondeHub list. Adjust if needed."); 212 } 213 214 await fetchPrediction(lookupSerialValue); 215 216 if (!lastSondePosition) { 217 locationErrors.textContent = "Could not find this serial in the SondeHub Database. You can still enter the location manually."; 218 locationErrors.className = "status-line error-text"; 219 useMyLocation(); 220 } 221 lookupStatus.textContent = "Lookup finished. Continue with the location below."; 222 lookupStatus.className = "status-line success-text"; 223 hasExistingRecovery = await fetchRecoveryReports(serial); 224 } catch (error) { 225 lookupStatus.textContent = error.message || "Lookup failed. "; 226 lookupStatus.className = "status-line error-text"; 227 locationErrors.textContent = "Could not find this serial in the SondeHub Database. You can still enter the location manually."; 228 locationErrors.className = "status-line"; 229 useMyLocation(); 230 hasExistingRecovery = await fetchRecoveryReports(serial); 231 await fetchPrediction(lookupSerialValue); 232 } finally { 233 showStep(3); 234 showStep(4); 235 ensureMap(); 236 map.invalidateSize(); 237 if (hasExistingRecovery && recoveryInfo) { 238 scrollIntoView(recoveryInfo); 239 } else { 240 scrollIntoView(steps[3]); 241 } 242 } 243 } 244 245 function applySondePosition(position, message) { 246 setMapLocation(position.lat, position.lon, true, position.alt); 247 lastSondePosition = position; 248 setSondeMarker(position.lat, position.lon); 249 locationStatus.textContent = message; 250 locationStatus.className = "status-line"; 251 locationErrors.textContent = ""; 252 locationErrors.className = "status-line"; 253 } 254 255 function extractPosition(data) { 256 const telemetry = Array.isArray(data?.telemetry) ? data.telemetry : null; 257 const lastTelemetry = telemetry && telemetry.length > 0 ? telemetry[telemetry.length - 1] : null; 258 const candidates = [ 259 data, 260 lastTelemetry, 261 data?.latest, 262 Array.isArray(data) ? data[data.length - 1] : null, 263 data?.position, 264 data?.locations?.[0] 265 ]; 266 267 for (const entry of candidates) { 268 if (!entry) continue; 269 const lat = parseFloat(entry.lat ?? entry.latitude ?? entry.gps_lat ?? entry.coords?.lat); 270 const lon = parseFloat(entry.lon ?? entry.longitude ?? entry.gps_lon ?? entry.coords?.lon); 271 const alt = parseFloat(entry.alt ?? entry.altitude ?? entry.gps_alt ?? entry.coords?.alt); 272 if (Number.isFinite(lat) && Number.isFinite(lon)) { 273 return { lat, lon, alt: Number.isFinite(alt) ? alt : null }; 274 } 275 } 276 return null; 277 } 278 279 function normalizeSerialKey(serial) { 280 return String(serial || "").trim().toUpperCase(); 281 } 282 283 function indexRecentSondes(data) { 284 const index = new Map(); 285 286 const extractNestedPosition = (record) => { 287 if (!record || typeof record !== "object" || Array.isArray(record)) return null; 288 289 const nestedPositions = Object.entries(record) 290 .map(([key, value], order) => ({ 291 order, 292 timeValue: Date.parse(key), 293 position: extractPosition(value) 294 })) 295 .filter((entry) => entry.position); 296 297 if (nestedPositions.length === 0) return null; 298 299 return nestedPositions.reduce((latest, entry) => { 300 if (!Number.isFinite(entry.timeValue)) { 301 return !latest || entry.order > latest.order ? entry : latest; 302 } 303 if (!latest || !Number.isFinite(latest.timeValue) || entry.timeValue > latest.timeValue) { 304 return entry; 305 } 306 return latest; 307 }, null).position; 308 }; 309 310 const addRecord = (record, serialHint = "") => { 311 const position = extractPosition(record) || extractNestedPosition(record); 312 if (!position) return; 313 314 const serials = [ 315 serialHint, 316 record?.serial, 317 record?.vehicle, 318 record?.id, 319 record?.callsign
320 ].map(normalizeSerialKey).filter(Boolean); 321 322 serials.forEach((serial) => index.set(serial, position)); 323 }; 324 325 if (Array.isArray(data)) { 326 data.forEach((record) => addRecord(record)); 327 } else if (data && typeof data === "object") { 328 Object.entries(data).forEach(([serial, record]) => addRecord(record, serial)); 329 } 330 331 return index; 332 } 333 334 function preloadRecentSondes() { 335 if (recentSondesPromise) return recentSondesPromise; 336 337 recentSondesPromise = fetch("https://api.v2.sondehub.org/sondes") 338 .then((response) => { 339 if (!response.ok) throw new Error("Recent SondeHub list unavailable."); 340 return response.json(); 341 }) 342 .then((data) => { 343 recentSondesBySerial = indexRecentSondes(data); 344 return recentSondesBySerial; 345 }) 346 .catch(() => { 347 recentSondesBySerial = new Map(); 348 return recentSondesBySerial; 349 }); 350 351 return recentSondesPromise; 352 } 353 354 async function fetchRecentSondePosition(serial) { 355 const index = recentSondesBySerial || await preloadRecentSondes(); 356 return index.get(normalizeSerialKey(serial)) || null; 357 } 358 359 function normalizeSerial(serial) { 360 const trimmed = serial.trim(); 361 if (selectedType?.id === "dfm17") { 362 const digits = trimmed.replace(/\D/g, ""); 363 if (digits.length >= 8) { 364 const aa = digits.slice(0, 2); 365 const cccccc = digits.slice(-6); 366 return `${aa}${cccccc}`; 367 } 368 } 369 return trimmed; 370 } 371 372 function applySerialInputFormat(typeId) { 373 if (typeId === "dfm17") { 374 serialInput.placeholder = "AABBB-CCCCCC"; 375 serialInput.maxLength = 12; 376 } else if (typeId === "dfm09") { 377 serialInput.placeholder = "6-digit serial"; 378 serialInput.maxLength = 6; 379 } else if (typeId === "m10" || typeId === "m20") { 380 serialInput.placeholder = "AAA-B-CCCCC"; 381 serialInput.maxLength = 11; 382 } else if (typeId === "imet4") { 383 serialInput.placeholder = "8 chars (A-Z, 0-9)"; 384 serialInput.maxLength = 8; 385 } else if (typeId === "rs41" || typeId === "rs92") { 386 serialInput.placeholder = defaultSerialPlaceholder; 387 serialInput.maxLength = 8; 388 } else { 389 serialInput.placeholder = "Serial number"; 390 serialInput.removeAttribute("maxLength"); 391 } 392 } 393 394 function updateSerialHint(type) { 395 if (!serialHint) return; 396 if (suppressSerialHint) { 397 serialHint.classList.add("hidden"); 398 serialHint.textContent = ""; 399 return; 400 } 401 serialHint.classList.remove("hidden"); 402 if (type?.id === "dfm17") { 403 serialHint.innerHTML = `DFM17 serial numbers are located on a sticker on the side of the sonde. Enter the full serial number including the dash. <br><img src="img/serials/dfm17_overview.jpg" alt="DFM17 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 404 return; 405 } 406 if (type?.id === "rs41") { 407 serialHint.innerHTML = `Most RS41s have a serial on the label at the bottom of the sonde. If this is a NWS sonde, use the serial on the sensor stalk. <br><img src="img/serials/rs41_overview.jpg" alt="RS41 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 408 return; 409 } 410 if (type?.id === "rs92") { 411 serialHint.innerHTML = `RS92 serial numbers are located on a sticker at the base of the sonde. <br><img src="img/serials/rs92_overview.jpg" alt="RS92 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 412 return; 413 } 414 if (type?.id === "m20") { 415 serialHint.innerHTML = `M20 serial numbers are located on a sticker on the side of the sonde. Enter the full serial number, with a dash between each number group.<br>`;// No image yet <img src="img/serials/m20_overview.jpg" alt="M20 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 416 return; 417 } 418 if (type?.id === "m10") { 419 serialHint.innerHTML = `M10 serial numbers are located on a sticker on the side of the sonde. Enter the full serial number, with a dash between each number group.<br>`;// No image yet <img src="img/serials/m10_overview.jpg" alt="M10 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 420 return; 421 } 422 if (type?.id === "dfm09") { 423 serialHint.innerHTML = `DFM09 serial numbers are located on a sticker on the side of the sonde.<br><img src="img/serials/dfm09_overview.jpg" alt="DFM09 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 424 return; 425 } 426 if (type?.id === "wxr301") { 427 serialHint.innerHTML = `WxR-301 serial numbers are located on a sticker on the base of the sonde.<br><img src="img/serials/wxr301_overview.jpg" alt="WxR-301 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 428 return; 429 } 430 if (type?.id === "imet54") { 431 serialHint.innerHTML = `iMet-54 serial numbers are located on a sticker on the sensor stalk.<br><img src="img/serials/imet54_overview.jpg" alt="WxR-301 serial location" style="max-width: 100%; border-radius: 6px; margin-top: 8px;">`; 432 return; 433 } 434 if (type?.id === "lms6") { 435 serialHint.innerHTML = `LMS6 serial numbers are located on a sticker on base of the sonde. Look for SN followed by numbers.`; 436 return; 437 } 438 if (type?.id === "imet4") { 439 serialHint.innerHTML = `iMet radiosondes do not transmit their serial number, so Sondehub receivers generate a serial number. Check <a href="https://sondehub.org" target="_blank" rel="noreferrer">sondehub.org</a> to find the serial number the radiosonde was uploaded with.`; 440 return; 441 } 442 const name = type?.full || "radiosonde";
443 serialHint.textContent = `Look for the serial on the label or casing of a ${name}. A diagram will appear here once available.`; 444 } 445 446 function ensureMap() { 447 if (map) return map; 448 map = L.map("map", { 449 center: [0, 0], 450 zoom: 2, 451 worldCopyJump: true 452 }); 453 454 const osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { 455 maxZoom: 19, 456 attribution: "© OpenStreetMap contributors" 457 }).addTo(map); 458 459 const esri = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { 460 maxZoom: 19, 461 attribution: "Tiles © Esri" 462 }); 463 464 L.control.layers({ "OpenStreetMap": osm, "ESRI Satellite": esri }).addTo(map); 465 466 marker = L.marker([0, 0], { draggable: true }).addTo(map); 467 marker.on("dragend", () => { 468 const pos = marker.getLatLng(); 469 updateLatLonInputs(pos.lat, pos.lng); 470 }); 471 updateLatLonInputs(0, 0); 472 473 return map; 474 } 475 476 function setPredictionMarker(lat, lon) { 477 ensureMap(); 478 const icon = L.divIcon({ 479 className: "prediction-marker", 480 html: '<div class="prediction-dot"></div>', 481 iconSize: [18, 18], 482 iconAnchor: [9, 9] 483 }); 484 if (predictionMarker) { 485 predictionMarker.setLatLng([lat, lon]); 486 } else { 487 predictionMarker = L.marker([lat, lon], { icon }).addTo(map).bindTooltip("Predicted landing", { permanent: false }); 488 } 489 } 490 491 function setPredictionPolyline(track) { 492 ensureMap(); 493 const latlngs = track.map((p) => [p.lat, p.lon]); 494 if (predictionPolyline) { 495 predictionPolyline.setLatLngs(latlngs); 496 } else { 497 predictionPolyline = L.polyline(latlngs, { color: "#00a3d3", weight: 3, opacity: 0.7 }).addTo(map); 498 } 499 } 500 501 function setSondeMarker(lat, lon) { 502 ensureMap(); 503 const icon = L.divIcon({ 504 className: "sonde-marker", 505 html: '<div class="sonde-dot"></div>', 506 iconSize: [18, 18], 507 iconAnchor: [9, 9] 508 }); 509 if (sondeMarker) { 510 sondeMarker.setLatLng([lat, lon]); 511 } else { 512 sondeMarker = L.marker([lat, lon], { icon }).addTo(map).bindTooltip("Last received location", { permanent: false }); 513 } 514 } 515 516 function clearPredictionDisplay() { 517 if (predictionMarker && map) { 518 map.removeLayer(predictionMarker); 519 } 520 if (predictionPolyline && map) { 521 map.removeLayer(predictionPolyline); 522 } 523 predictionMarker = null; 524 predictionPolyline = null; 525 lastPredictionPosition = null; 526 } 527 528 function updateLatLonInputs(lat, lon) { 529 latInput.value = Number.isFinite(lat) ? lat.toFixed(5) : ""; 530 lonInput.value = Number.isFinite(lon) ? lon.toFixed(5) : ""; 531 } 532 533 function updateAltInput(alt) { 534 altInput.value = Number.isFinite(alt) ? alt.toFixed(1) : ""; 535 } 536 537 function setMapLocation(lat, lon, center = false, alt = null) { 538 ensureMap(); 539 const safeLat = Number.isFinite(lat) ? lat : 0; 540 const safeLon = Number.isFinite(lon) ? lon : 0; 541 marker.setLatLng([safeLat, safeLon]); 542 updateLatLonInputs(safeLat, safeLon); 543 if (alt !== null && Number.isFinite(alt)) { 544 updateAltInput(alt); 545 } 546 if (center) {
547 map.setView([safeLat, safeLon], 12); 548 } else{ 549 map.panTo([safeLat, safeLon]) 550 } 551 } 552 553 function handleCoordinateInput() { 554 const lat = parseFloat(latInput.value); 555 const lon = parseFloat(lonInput.value); 556 const alt = parseFloat(altInput.value); 557 if (!Number.isFinite(lat) || !Number.isFinite(lon)) { 558 locationStatus.textContent = "Enter valid latitude and longitude values."; 559 locationStatus.className = "status-line error-text"; 560 return; 561 } 562 setMapLocation(lat, lon, false, Number.isFinite(alt) ? alt : null); 563 locationStatus.textContent = "Location updated from manual entry."; 564 locationStatus.className = "status-line success-text"; 565 } 566 567 function useMyLocation() { 568 if (!navigator.geolocation) { 569 locationStatus.textContent = "Geolocation is not supported in this browser."; 570 locationStatus.className = "status-line error-text"; 571 return false; 572 } 573 574 locationStatus.textContent = "Requesting your locationâ¦"; 575 locationStatus.className = "status-line loading"; 576 577 navigator.geolocation.getCurrentPosition( 578 (pos) => { 579 const { latitude, longitude } = pos.coords; 580 setMapLocation(latitude, longitude, true, pos.coords.altitude ?? null); 581 locationStatus.textContent = "Map set to your current location. Adjust if needed."; 582 locationStatus.className = "status-line success-text"; 583 return true; 584 }, 585 (err) => { 586 locationStatus.textContent = `Could not get your location (${err.message}).`; 587 locationStatus.className = "status-line error-text"; 588 return false; 589 }, 590 { enableHighAccuracy: true, timeout: 10000 } 591 ); 592 return true; 593 } 594 595 function preparePayload() { 596 const serial = serialInput.value.trim(); 597 if (!selectedType) { 598 return { error: "Select a radiosonde type first." }; 599 } 600 const validation = validateSerial(serial); 601 if (!validation.valid) { 602 return { error: validation.message }; 603 } 604 605 const lat = parseFloat(latInput.value); 606 const lon = parseFloat(lonInput.value); 607 const alt = parseFloat(altInput.value); 608 if (!Number.isFinite(lat) || !Number.isFinite(lon)) { 609 return { error: "Latitude and longitude are required for the report." }; 610 } 611 612 const recovered = recoveryForm.recovered.value === "yes"; 613 const planned = !recovered && recoveryForm.planned.value === "yes"; 614 const name = document.getElementById("nameInput").value.trim(); 615 const email = document.getElementById("emailInput").value.trim(); 616 const notes = document.getElementById("notesInput").value.trim(); 617 618 if (!name) { 619 return { error: "Your name or callsign is required." }; 620 } 621 622 const payload = { 623 recovery_software: "sondehub.org/found", 624 serial: normalizeSerial(serial), 625 lat, 626 lon, 627 alt: Number.isFinite(alt) ? alt : 0.0, 628 recovered, 629 planned, 630 recovered_by: name, 631 description: notes 632 }; 633 634 if (email) { 635 payload.contact_email = email; 636 } 637 638 return { payload }; 639 } 640 641 async function submitRecovery(event) { 642 event.preventDefault(); 643 const result = preparePayload(); 644 if (result.error) { 645 submitStatus.textContent = result.error; 646 submitStatus.className = "status-line error-text"; 647 return; 648 } 649 650 submitStatus.textContent = "Submitting recovery report to SondeHubâ¦"; 651 submitStatus.className = "status-line loading"; 652 653 try { 654 const response = await fetch("https://api.v2.sondehub.org/recovered", { 655 method: "PUT", 656 headers: { "Content-Type": "application/json" }, 657 body: JSON.stringify(result.payload) 658 }); 659 660 if (!response.ok) { 661 const message = await safeReadError(response); 662 throw new Error(message || "Failed to submit recovery report."); 663 } 664 665 submitStatus.textContent = "Reported successfully. Thank you for contributing!"; 666 submitStatus.className = "status-line success-text"; 667 } catch (err) { 668 submitStatus.textContent = err.message || "Submission failed. Please try again."; 669 submitStatus.className = "status-line error-text"; 670 } 671 } 672 673 async function safeReadError(response) { 674 try { 675 const data = await response.json(); 676 return data?.message || ""; 677 } catch { 678 return ""; 679 } 680 } 681 682 function togglePlannedVisibility() { 683 const recovered = recoveryForm.recovered.value === "yes"; 684 plannedWrapper.classList.toggle("hidden", recovered); 685 } 686 687 function scrollIntoView(el) { 688 if (!el) return; 689 el.scrollIntoView({ behavior: "smooth", block: "start" }); 690 } 691 692 function formatDfmSerial(value) { 693 const digits = value.replace(/\D/g, "").slice(0, 11); 694 const first = digits.slice(0, 5); 695 const last = digits.slice(5, 11); 696 return last ? `${first}-${last}` : first; 697 } 698 699 function formatM10Serial(value) { 700 const cleaned = value.replace(/[^A-Za-z0-9]/g, "").slice(0, 9); 701 const part1 = cleaned.slice(0, 3); 702 const part2 = cleaned.slice(3, 4); 703 const part3 = cleaned.slice(4, 9); 704 if (part3) return `${part1}-${part2}-${part3}`; 705 if (part2) return `${part1}-${part2}`; 706 return part1; 707 } 708 709 function formatTimestamp(ts) { 710 if (!ts) return ""; 711 const date = typeof ts === "number" ? new Date(ts * 1000) : new Date(ts); 712 if (Number.isNaN(date.getTime())) return "";
713 return date.toLocaleString(); 714 } 715 716 function autoLookupFromQuery() { 717 const params = new URLSearchParams(window.location.search); 718 const serialParam = params.get("serial"); 719 if (!serialParam) return; 720 721 suppressSerialHint = true; 722 723 if (!selectedType) { 724 const defaultType = sondeTypes.find((t) => t.id === "rs41") || sondeTypes[0]; 725 const defaultCard = defaultType ? typeGrid.querySelector(`[data-id="${defaultType.id}"]`) : null; 726 if (defaultType && defaultCard) { 727 selectType(defaultType, defaultCard); 728 } 729 } 730 731 serialInput.value = serialParam.trim(); 732 setTimeout(() => scrollIntoView(document.getElementById("serialInput")), 0); 733 setTimeout(() => lookupSerial(), 0); 734 } 735 736 function extractPredictionPosition(resp) { 737 const candidates = []; 738 if (Array.isArray(resp)) candidates.push(...resp); 739 if (resp && typeof resp === "object") { 740 if (Array.isArray(resp.predictions)) candidates.push(...resp.predictions); 741 if (resp.data) candidates.push(resp); 742 Object.values(resp).forEach((v) => { 743 if (Array.isArray(v)) candidates.push(...v); 744 }); 745 } 746 747 const parseDataArray = (value) => { 748 if (Array.isArray(value)) return value; 749 if (typeof value === "string") { 750 try { 751 const parsed = JSON.parse(value); 752 if (Array.isArray(parsed)) return parsed; 753 } catch { 754 return null; 755 } 756 } 757 return null; 758 }; 759 760 for (const entry of candidates) { 761 const observed = extractPosition(entry); 762 const dataArr = parseDataArray(entry?.data) || parseDataArray(entry); 763 if (Array.isArray(dataArr) && dataArr.length > 0) { 764 const last = dataArr[dataArr.length - 1]; 765 const lat = parseFloat(last.lat ?? last.latitude); 766 const lon = parseFloat(last.lon ?? last.longitude); 767 const alt = parseFloat(last.alt ?? last.altitude); 768 if (Number.isFinite(lat) && Number.isFinite(lon)) { 769 const track = dataArr 770 .map((p) => ({ 771 lat: parseFloat(p.lat ?? p.latitude), 772 lon: parseFloat(p.lon ?? p.longitude), 773 alt: parseFloat(p.alt ?? p.altitude) 774 })) 775 .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon)); 776 return { observed, last: { lat, lon, alt: Number.isFinite(alt) ? alt : null }, track }; 777 } 778 } 779 if (observed) { 780 return { observed, last: null, track: [] }; 781 } 782 } 783 return null; 784 } 785 786 async function fetchPrediction(serial) { 787 try { 788 const response = await fetch(`https://api.v2.sondehub.org/predictions?vehicles=${encodeURIComponent(serial)}`); 789 if (!response.ok) throw new Error("No prediction available."); 790 const data = await response.json(); 791 const prediction = extractPredictionPosition(data); 792 if (!prediction) return null; 793 794 if (!lastSondePosition && prediction.observed) { 795 applySondePosition(prediction.observed, "Last observed sonde position loaded from SondeHub predictions. Adjust if needed."); 796 } 797 798 const sondeAlt = lastSondePosition?.alt; 799 const predAlt = prediction.last?.alt; 800 const showPrediction = Number.isFinite(sondeAlt) && Number.isFinite(predAlt) && (sondeAlt - predAlt) > 300; 801 if (!showPrediction) { 802 clearPredictionDisplay(); 803 return null; 804 } 805 806 lastPredictionPosition = prediction.last; 807 setPredictionMarker(prediction.last.lat, prediction.last.lon); 808 if (prediction.track.length > 1) { 809 setPredictionPolyline(prediction.track); 810 } 811 812 setMapLocation(prediction.last.lat, prediction.last.lon, true, prediction.last.alt); 813 locationStatus.textContent = "Using predicted landing position as default (sonde still high)."; 814 locationStatus.className = "status-line"; 815 return prediction; 816 } catch (err) { 817 clearPredictionDisplay(); 818 return null; 819 } 820 } 821 822 renderSondeTypes(); 823 preloadRecentSondes(); 824 lookupBtn.addEventListener("click", lookupSerial); 825 serialInput.addEventListener("keydown", (e) => { 826 if (e.key === "Enter") { 827 e.preventDefault(); 828 lookupSerial(); 829 } 830 }); 831 serialInput.addEventListener("input", () => { 832 if (selectedType?.id === "dfm17") { 833 serialInput.value = formatDfmSerial(serialInput.value); 834 } else if (selectedType?.id === "m10" || selectedType?.id === "m20") { 835 serialInput.value = formatM10Serial(serialInput.value); 836 } 837 if (serialInput.classList.contains("input-error")) { 838 const check = validateSerial(serialInput.value.trim()); 839 if (check.valid) { 840 serialInput.classList.remove("input-error"); 841 lookupStatus.textContent = "Enter the serial number printed on this radiosonde."; 842 lookupStatus.className = "status-line"; 843 } 844 } 845 }); 846 latInput.addEventListener("change", handleCoordinateInput); 847 lonInput.addEventListener("change", handleCoordinateInput); 848 useLocationBtn.addEventListener("click", useMyLocation); 849 recoveryForm.addEventListener("submit", submitRecovery);
850 Array.from(recoveryForm.recovered).forEach((input) => { 851 input.addEventListener("change", togglePlannedVisibility); 852 }); 853 854 togglePlannedVisibility(); 855 autoLookupFromQuery(); 856 if (step1) { 857 step1.addEventListener("click", () => { 858 if (step1.classList.contains("collapsed")) { 859 step1.classList.remove("collapsed"); 860 scrollIntoView(step1); 861 } 862 }); 863 } 864});
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.