PageSourceSearch

https://sondehub.org/found/app.js

js sondehub.org collected 2026-10-02 03:55:46 UTC 32,534 bytes, 864 lines download raw bytes

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: "&copy; 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 &copy; 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.