/* Copyright (c) 2023-forever Douglas Malnati. All rights reserved. See the /faq/tos page for details. (If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.) */ import * as utl from '/js/Utl.js'; import { Base } from './Base.js'; import { CollapsableTitleBox, DialogBox } from './DomWidgets.js'; import { MsgDefinitionInputUiController } from './MsgDefinitionInputUiController.js'; import { WSPR } from '/js/WSPR.js'; import { TabularData } from '../../../../js/TabularData.js'; // Class takes care to get new search results for a given WsprSearch. // // Without Channels: // - Just search aligned with every 2 minutes // // With Channels: // - A given band and channel will indicate the start minute of a given // 10-minute window. // - The class wants to wait for all data in a 10-min window to become available // before searching again. // // In both channel and non-channel: // - It takes time for data to make its way through the wspr databases, often over // a minute since transmission. // - So offset the search time by 1 minute or more to "wait" for the data // - Avoid thundering herd by offsetting the exact search start time by some random amount. // // To qualify to auto-refresh, the current time must be within the window of time specified // by the search parameters. // - Now must be greater or equal to the start time. // - Now must be less than or equal to the end time // - If the end time does not exist, it is considered infinite, and so now is less than it // class WsprRefreshTimer { constructor() { this.fn = () => {}; this.timerId = null; this.msTarget = 0; this.band = ""; this.channel = "0"; this.gte = ""; this.lte = ""; } SetCallbackOnTimeout(fn) { this.fn = fn; } SetNewTimerSearchParameters(band, channel, gte, lte) { this.band = band; this.channel = channel; this.gte = gte; this.lte = lte; this.#ScheduleNextTimeout(); } Cancel() { clearTimeout(this.timerId); this.timerId = null; this.msTarget = 0; } // can return null or a 0+ value // null returned when there is no timeout set GetTimeToNextTimeoutMs() { let ms = null; if (this.msTarget != 0) { let msNow = utl.Now(); if (msNow <= this.msTarget) { ms = this.msTarget - msNow; } } return ms; } #OnTimeout() { this.fn(); this.#ScheduleNextTimeout(); } #ScheduleNextTimeout() { this.Cancel(); let dbg = false; let msSince = utl.MsUntilDate(this.gte); if (dbg) console.log(`msSince: ${msSince}`) if (msSince > 0) { return; } let msUntil = utl.MsUntilDate(this.lte); if (dbg) console.log(`msUntil: ${msUntil}`) if (msUntil < 0) { return; } let cd = WSPR.GetChannelDetails(this.band, this.channel); // Take current time let msNow = utl.Now(); let dtNow = utl.MakeDateTimeFromMs(msNow); // `${YYYY}-${MM}-${DD} ${hh}:${mm}:${ss}` let minNow = dtNow.substr(15, 1); // single digit if (dbg) console.log(`minNow: ${minNow} from ${dtNow}`) // figure the minute when prior transmission should have completed, we build from there let windowStartMin = cd.min; // override for channel-less operation if (this.channel == "") { let evenMinNow = Math.floor(minNow / 2) * 2; windowStartMin = evenMinNow; } if (dbg) console.log(`windowStartMin: ${windowStartMin}`); // how far into the future is the next window? // calculate when the next time the window minute occurs in relation to now let msIntoTheFuture = 0; if (minNow == windowStartMin) { msIntoTheFuture = 0; } else if (minNow < windowStartMin) { msIntoTheFuture = (windowStartMin - minNow) * 60 * 1000; } else // minNow > targetMin { if (this.channel == "") { msIntoTheFuture = (2 - (minNow - windowStartMin)) * 60 * 1000; } else { msIntoTheFuture = (10 - (minNow - windowStartMin)) * 60 * 1000; } } if (dbg) console.log(`windowStart is ${msIntoTheFuture / 60000} min ${utl.Commas(msIntoTheFuture)} ms in the future (whole minutes)`) // what time will it be at that start minute? // first, create the time of day it will be let msThen = msNow + msIntoTheFuture; let dtThen = utl.MakeDateTimeFromMs(msThen); if (dbg) console.log(`time then: ${dtThen}`) // strip the seconds off and replace to make an even minute let dtThenMinutePrecise = dtThen.substring(0, dtThen.length - 2) + "00"; if (dbg) console.log(`time then (min precise): ${dtThenMinutePrecise}`) // look at the ms time at the minute-price time let msThenMinutePrecise = utl.ParseTimeToMs(dtThenMinutePrecise); // add buffer let msTarget = msThenMinutePrecise; msTarget += 1 * 60 * 1000; // add a minute buffer msTarget += 7 * 1000; // add a bit more because it's not always on time msTarget += Math.floor(Math.random() * 5000); // jitter to avoid thundering herd if (dbg) console.log(`buffer added ${utl.Commas(msTarget - msThenMinutePrecise)}`) if (dbg) console.log(`target now: ${utl.MakeDateTimeFromMs(msTarget)}`) // distance into the future from now let msDiff = msTarget - msNow; if (dbg) console.log(`msDiff: ${utl.Commas(msDiff)}`); // channel to minute // 581 = 0 // 582 = 2 // 583 = 4 // 584 = 6 // 585 = 8 // schedule this.timerId = setTimeout(() => { this.#OnTimeout(); }, msDiff); this.msTarget = msTarget; }; } export class WsprSearchUiInputController extends Base { constructor(cfg) { super(); this.cfg = cfg; this.ok = this.cfg.container; if (this.ok) { this.mdiUdList = []; this.mdiVdList = []; this.showInitialLoadProgressOnNextSearch = false; this.initialLoadProgressDialogShown = false; this.ui = this.#MakeUI(); this.cfg.container.appendChild(this.ui); this.startupGuidanceDialog = this.#MakeStartupGuidanceDialog(); this.startupGuidanceDialog.GetUI().style.top = "100px"; this.startupGuidanceDialog.GetUI().style.left = "100px"; document.body.appendChild(this.startupGuidanceDialog.GetUI()); this.initialLoadProgressModal = this.#MakeInitialLoadProgressModal(); document.body.appendChild(this.initialLoadProgressModal); this.#WireHeaderHelpLink(); this.refreshTimer = new WsprRefreshTimer(); this.refreshTimer.SetCallbackOnTimeout(() => { console.log(`timed out`); // cyclical, but who cares this.#Search(); }); // A user initiates this, so causing url serialization and // a history entry makes sense this.buttonInput.addEventListener('click', () => { let ok = this.#ValidateInputs(); if (ok) { this.Emit("REQ_URL_GET"); this.#Search(); } else { this.#OnBadSearchAttempt(); } }); this.timerId = null; this.td = null; } } GetBand() { return this.bandSelect.value; } SetBand(val) { this.bandSelect.value = val; } GetChannel() { return this.channelInput.value; } SetChannel(val) { this.channelInput.value = val; } GetCallsign() { return this.callsignInput.value.toUpperCase(); } SetCallsign(val) { this.callsignInput.value = val } GetGte() { return this.gteInput.value; } SetGte(val) { this.gteInput.value = val; } GetLte() { return this.#ConvertLte(this.lteInput.value); } GetLteRaw() { return this.lteInput.value; } SetLte(val) { this.lteInput.value = val; } #ConvertLte(lte) { // let the end time (date) be inclusive // so if you have 2023-04-28 as the end date, everything for the entire // day should be considered. // since the querying system wants a cutoff date (lte datetime), we // just shift the date of today forward by an entire day, changing it from // a cutoff of today at morning midnight to tomorrow at morning midnight. // throw in an extra hour for daylight savings time scenarios let retVal = lte; if (lte != "") { let ms = utl.ParseTimeToMs(lte); ms += (25 * 60 * 60 * 1000); retVal = utl.MakeDateFromMs(ms); } return retVal; } OnEvent(evt) { switch (evt.type) { case "ON_URL_SET": this.#OnUrlSet(evt); break; case "ON_URL_GET": this.#OnUrlGet(evt); break; case "SEARCH_PROGRESS": this.#OnSearchProgress(evt); break; case "SEARCH_COMPLETE": this.#OnSearchComplete(); break; case "DATA_TABLE_RAW_READY": this.#OnDataTableRawReady(evt); break; } } #OnUrlSet(evt) { this.SetBand(WSPR.GetDefaultBandIfNotValid(evt.Get("band", "20m"))); this.SetChannel(evt.Get("channel", "")); this.SetCallsign(evt.Get("callsign", "")); this.SetGte(evt.Get("dtGte", "")); this.SetLte(evt.Get("dtLte", "")); for (let slot = 0; slot < 5; ++slot) { // set what could be a blank msg this.mdiUdList[slot].SetMsgDefinition(this.#GetUrlMsgDefinition(evt, slot, "user")); // trigger logic elsewhere to update display of slot header this.mdiUdList[slot].GetOnApplyCallback()(false); } for (let slot = 0; slot < 5; ++slot) { // set what could be a blank msg this.mdiVdList[slot].SetMsgDefinition(this.#GetUrlMsgDefinition(evt, slot, "vendor")); // trigger logic elsewhere to update display of slot header this.mdiVdList[slot].GetOnApplyCallback()(false); } this.showInitialLoadProgressOnNextSearch = true; let ok = this.#ValidateInputsAndMaybeSearch(); if (!ok) { this.showInitialLoadProgressOnNextSearch = false; this.Emit("SHOW_EMPTY_MAP"); window.setTimeout(() => { this.#ShowStartupGuidanceDialog(); }, 250); } } #OnUrlGet(evt) { evt.Set("band", this.GetBand()); evt.Set("channel", this.GetChannel()); evt.Set("callsign", this.GetCallsign()); evt.Set("dtGte", this.GetGte()); evt.Set("dtLte", this.GetLteRaw()); for (let slot = 0; slot < 5; ++slot) { evt.Set(`slot${slot + 1}MsgDefUserDefined`, this.mdiUdList[slot].GetMsgDefinition()); } for (let slot = 0; slot < 5; ++slot) { evt.Set(`slot${slot + 1}MsgDefVendorDefined`, this.mdiVdList[slot].GetMsgDefinition()); } } #ValidateInputs() { let ok = true; if (this.GetCallsign() == "") { ok = false; this.callsignInput.style.backgroundColor = "pink"; } else { this.callsignInput.style.backgroundColor = "white"; this.callsignInput.style.backgroundColor = ""; } if (this.GetGte() == "") { let msNow = utl.Now(); let msThen = msNow - (30 * 24 * 60 * 60 * 1000); this.SetGte(utl.MakeDateFromMs(msThen)); } if (this.GetGte() != "" && this.GetLteRaw() != "") { const d1 = Date.parse(this.GetGte()); const d2 = Date.parse(this.GetLteRaw()); if (d2 < d1) { ok = false; this.gteInput.style.backgroundColor = "pink"; this.lteInput.style.backgroundColor = "pink"; } else { this.lteInput.style.backgroundColor = ""; } } else { this.lteInput.style.backgroundColor = ""; } return ok; } #GetUrlMsgDefinition(evt, slot, type) { // During the slot-number migration period, keep accepting the // historical one-based parameter names in the URL while the visible // UI uses zero-based slot numbering. let keySuffix = type == "user" ? "MsgDefUserDefined" : "MsgDefVendorDefined"; let keyList = [ `slot${slot + 1}${keySuffix}`, ]; if (type == "user") { keyList.push(`slot${slot + 1}MsgDef`); } for (let key of keyList) { let val = evt.Get(key, ""); if (val !== "") { return val; } } return ""; } #Search() { let msgDefinitionUserDefinedList = []; for (let fdi of this.mdiUdList) { msgDefinitionUserDefinedList.push(fdi.GetMsgDefinition()); } let msgDefinitionVendorDefinedList = []; for (let fdi of this.mdiVdList) { msgDefinitionVendorDefinedList.push(fdi.GetMsgDefinition()); } this.Emit({ type: "SEARCH_REQUESTED", band: this.GetBand(), channel: this.GetChannel(), callsign: this.GetCallsign(), gte: this.GetGte(), lte: this.GetLte(), lteRaw: this.GetLteRaw(), msgDefinitionUserDefinedList, msgDefinitionVendorDefinedList, }); this.#OnSearchStart(); this.refreshTimer.SetNewTimerSearchParameters(this.GetBand(), this.GetChannel(), this.GetGte(), this.GetLteRaw()); this.#SetStatusUpdated(); this.#StartPeriodicStatusUpdateTimer(); } #StartPeriodicStatusUpdateTimer() { clearInterval(this.timerId); this.timerId = null; const REFRESH_INTERVAL_MS = 10000; this.timerId = setInterval(() => { this.#SetStatusUpdated(); this.#SetStatusLastSeen(); }, REFRESH_INTERVAL_MS); } #SetStatusUpdated() { let statusUpdated = ""; let msUntil = this.refreshTimer.GetTimeToNextTimeoutMs(); if (msUntil != null) { // set when last update happened let dt = utl.MakeDateTimeFromMs(utl.Now()).substr(0, 16); let dtJustTime = dt.substr(11, 16); statusUpdated += `Updated ${dtJustTime}, `; // set when next update will happen let nextMinStr = utl.MsToDurationStrMinutes(msUntil); if (nextMinStr.charAt(0) != "0") { statusUpdated += `next in ${nextMinStr}`; } else { statusUpdated += `next < 1 min`; } } else { // bad search statusUpdated = ""; } // display this.statusUpdated.innerHTML = statusUpdated; } #OnDataTableRawReady(evt) { this.td = evt.tabularDataReadOnly; this.#HideInitialLoadProgressDialog(); this.#SetStatusLastSeen(); } #SetStatusLastSeen() { let latestAgeStr = ``; if (this.td != null) { if (this.td.Length() >= 1) { let dtLast = this.td.Get(0, "DateTimeLocal"); let msLast = utl.ParseTimeToMs(dtLast); let msNow = utl.Now(); let msDiff = msNow - msLast; let durationStr = utl.MsToDurationStrDaysHoursMinutes(msDiff); let durationStrTrimmed = utl.DurationStrTrim(durationStr); latestAgeStr = `Since last seen: ${durationStrTrimmed}`; } else { latestAgeStr = `Since last seen: [none found]`; } } else { // nothing to do } this.statusLastSeen.innerHTML = latestAgeStr; } #ValidateInputsAndMaybeSearch() { let ok = this.#ValidateInputs(); if (ok) { this.#Search(); } else { this.#OnBadSearchAttempt(); } return ok; } #OnBadSearchAttempt() { this.refreshTimer.Cancel() this.#SetStatusUpdated(); this.td = null; this.#SetStatusLastSeen(); } #MakeStartupGuidanceDialog() { let dlg = new DialogBox(); dlg.SetTitleBar("Dashboard Help"); let body = dlg.GetContentContainer(); body.style.padding = "12px"; body.style.minWidth = "420px"; body.style.maxWidth = "560px"; body.style.backgroundColor = "rgb(245, 245, 245)"; let intro = document.createElement("div"); intro.textContent = "Use this dashboard to search for WSPR spots and inspect the results in the map, charts, data table, and stats."; intro.style.marginBottom = "10px"; intro.style.fontWeight = "600"; body.appendChild(intro); let p1 = document.createElement("div"); p1.textContent = "Next steps:"; p1.style.marginBottom = "6px"; body.appendChild(p1); let ol = document.createElement("ol"); ol.style.marginTop = "0"; ol.style.paddingLeft = "22px"; for (let text of [ "Enter a callsign to search for.", "Adjust the band, channel, and date range if needed.", "Review or edit any custom UserDefined / VendorDefined message definitions.", "Click Search to populate the map, charts, data table, stats.", ]) { let li = document.createElement("li"); if (text == "Adjust the band, channel, and date range if needed.") { li.appendChild(document.createTextNode("Adjust the band, ")); let a = document.createElement("a"); a.href = "/channelmap/"; a.target = "_blank"; a.textContent = "channel"; li.appendChild(a); li.appendChild(document.createTextNode(", and date range if needed.")); } else { li.textContent = text; } li.style.marginBottom = "4px"; ol.appendChild(li); } body.appendChild(ol); let note = document.createElement("div"); note.textContent = "If no valid search has run yet, the map can still load, but no spots will appear until you search."; note.style.marginTop = "10px"; body.appendChild(note); let buttonRow = document.createElement("div"); buttonRow.style.marginTop = "14px"; buttonRow.style.display = "flex"; buttonRow.style.justifyContent = "flex-end"; let btn = document.createElement("button"); btn.type = "button"; btn.textContent = "OK"; btn.addEventListener("click", () => { dlg.Hide(); this.callsignInput?.focus?.(); }); buttonRow.appendChild(btn); body.appendChild(buttonRow); return dlg; } #MakeInitialLoadProgressModal() { let overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.inset = "0"; overlay.style.display = "none"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.18)"; overlay.style.zIndex = "10000"; let panel = document.createElement("div"); panel.style.position = "fixed"; panel.style.boxSizing = "border-box"; panel.style.padding = "14px"; panel.style.minWidth = "320px"; panel.style.maxWidth = "420px"; panel.style.backgroundColor = "rgb(245, 245, 245)"; panel.style.border = "1px solid rgb(160, 160, 160)"; panel.style.boxShadow = "0 6px 22px rgba(0, 0, 0, 0.22)"; let intro = document.createElement("div"); intro.textContent = "Loading dashboard results. Stand by!"; intro.style.marginBottom = "12px"; intro.style.fontWeight = "600"; panel.appendChild(intro); let row = document.createElement("div"); row.style.display = "flex"; row.style.alignItems = "center"; row.style.gap = "10px"; row.style.marginBottom = "12px"; let spinner = document.createElement("div"); spinner.style.width = "18px"; spinner.style.height = "18px"; spinner.style.border = "3px solid #d8d8d8"; spinner.style.borderTop = "3px solid #3498db"; spinner.style.borderRadius = "50%"; spinner.style.animation = "spin 1.0s linear infinite"; row.appendChild(spinner); let text = document.createElement("div"); text.textContent = "Search is in progress."; row.appendChild(text); panel.appendChild(row); let progressTrack = document.createElement("div"); progressTrack.style.boxSizing = "border-box"; progressTrack.style.width = "100%"; progressTrack.style.height = "14px"; progressTrack.style.border = "1px solid rgb(180, 180, 180)"; progressTrack.style.backgroundColor = "white"; progressTrack.style.marginBottom = "8px"; progressTrack.style.overflow = "hidden"; let progressFill = document.createElement("div"); progressFill.style.height = "100%"; progressFill.style.width = "0%"; progressFill.style.backgroundColor = "#3498db"; progressFill.style.transition = "width 120ms ease"; progressTrack.appendChild(progressFill); panel.appendChild(progressTrack); let progressLabel = document.createElement("div"); progressLabel.textContent = "Searching..."; progressLabel.style.marginBottom = "10px"; panel.appendChild(progressLabel); let note = document.createElement("div"); note.textContent = "This dialog will close automatically when the results load, or you can dismiss it now."; panel.appendChild(note); let buttonRow = document.createElement("div"); buttonRow.style.marginTop = "14px"; buttonRow.style.display = "flex"; buttonRow.style.justifyContent = "flex-end"; let btn = document.createElement("button"); btn.type = "button"; btn.textContent = "Dismiss"; btn.addEventListener("click", () => { overlay.style.display = "none"; }); buttonRow.appendChild(btn); panel.appendChild(buttonRow); overlay.appendChild(panel); overlay.progressFill = progressFill; overlay.progressLabel = progressLabel; overlay.panel = panel; return overlay; } #ShowStartupGuidanceDialog() { if (!this.startupGuidanceDialog) { return; } this.startupGuidanceDialog.Show(); } #ShowInitialLoadProgressDialog() { if (!this.initialLoadProgressModal || this.initialLoadProgressDialogShown) { return; } this.#SetInitialLoadProgress(0, "Searching..."); this.#PositionInitialLoadProgressModal(); this.initialLoadProgressModal.style.display = "flex"; this.initialLoadProgressDialogShown = true; } #HideInitialLoadProgressDialog() { if (!this.initialLoadProgressModal) { return; } this.initialLoadProgressModal.style.display = "none"; } #SetInitialLoadProgress(ratio, label) { if (!this.initialLoadProgressModal) { return; } let pct = Math.max(0, Math.min(100, Math.round((ratio ?? 0) * 100))); if (this.initialLoadProgressModal.progressFill) { this.initialLoadProgressModal.progressFill.style.width = `${pct}%`; } if (this.initialLoadProgressModal.progressLabel) { this.initialLoadProgressModal.progressLabel.textContent = label ?? ""; } } #PositionInitialLoadProgressModal() { let panel = this.initialLoadProgressModal?.panel; if (!panel) { return; } let mapEl = this.cfg.mapContainer; if (mapEl) { let rect = mapEl.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { panel.style.left = `${Math.round(rect.left + rect.width / 2)}px`; panel.style.top = `${Math.round(rect.top + rect.height / 2)}px`; panel.style.transform = "translate(-50%, -50%)"; return; } } panel.style.left = "50vw"; panel.style.top = "20vh"; panel.style.transform = "translateX(-50%)"; } #OnSearchProgress(evt) { this.#SetInitialLoadProgress(evt.ratio ?? 0, evt.label ?? "Search is in progress."); } #WireHeaderHelpLink() { let link = this.cfg.helpLink; if (!link) { return; } link.addEventListener("click", e => { e.preventDefault(); this.#ShowStartupGuidanceDialog(); }); } #OnSearchStart() { this.spinner.style.animationPlayState = "running"; if (this.showInitialLoadProgressOnNextSearch) { this.showInitialLoadProgressOnNextSearch = false; this.#ShowInitialLoadProgressDialog(); } } #OnSearchComplete() { this.spinner.style.animationPlayState = "paused"; this.#HideInitialLoadProgressDialog(); } #SubmitOnEnter(e) { if (e.key === "Enter") { e.preventDefault(); this.buttonInput.click(); } } #NoSpaces(e) { let retVal = true; if (e.which === 32) { e.preventDefault(); retVal = false; } return retVal; } #MakeConfigurationButtonInput(type) { let mdiList = type == "user" ? this.mdiUdList : this.mdiVdList; let title = type == "user" ? "Custom Message Definitions" : "Vendor Message Definitions"; let button = document.createElement('button'); button.innerHTML = "⚙️"; button.style.padding = '1px'; let container = document.createElement('span'); container.appendChild(button); // activate dialog box let dialogBox = new DialogBox(); document.body.appendChild(dialogBox.GetUI()); dialogBox.SetTitleBar(`⚙️ ${title}`); button.addEventListener('click', () => { dialogBox.ToggleShowHide(); }); // get place to put dialog box contents let dbContainer = dialogBox.GetContentContainer(); // stack inputs dbContainer.style.display = "flex"; dbContainer.style.flexDirection = "column"; dbContainer.style.gap = "2px"; dbContainer.style.maxHeight = "700px"; // build interface for all slots for (let slot = 0; slot < 5; ++slot) { // make a collapsing title box for each input let titleBox = new CollapsableTitleBox(); titleBox.SetTitle(`Slot ${slot}`) let titleBoxUi = titleBox.GetUI(); let titleBoxContainer = titleBox.GetContentContainer(); titleBox.SetMinWidth('813px'); // get a message definition input to put in the title box let mdi = new MsgDefinitionInputUiController(); mdiList.push(mdi); let mdiUi = mdi.GetUI(); mdi.SetDisplayName(`Slot${slot}`); mdi.SetDownloadFileNamePart(`Slot${slot + 1}`); let suffix = ""; // set msg def mdi.SetOnApplyCallback((preventUrlGet) => { // console.log(`slot ${slot + 1} msg def updated`); let msgDef = mdi.GetMsgDefinition(); // console.log(msgDef); suffix = msgDef == "" ? "" : " (Set)"; titleBox.SetTitle(`Slot ${slot}${suffix}`) // trigger url update unless asked not to (eg by simulated call) if (preventUrlGet !== false) { this.Emit("REQ_URL_GET"); } }); mdi.SetOnErrStateChangeCallback((ok) => { if (ok == false) { titleBox.SetTitle(`Slot ${slot} (Error)`); } else { titleBox.SetTitle(`Slot ${slot}${suffix}`) } }); // pack the msg def input into the title box titleBoxContainer.appendChild(mdiUi); // pack the titleBox into the dialog box dbContainer.appendChild(titleBoxUi); } // Create bulk import/export buttons let buttonBar = document.createElement('div'); let buttonFromFile = document.createElement('button'); let buttonToFile = document.createElement('button'); buttonFromFile.innerHTML = "From File (Bulk)"; buttonToFile.innerHTML = "To File (Bulk)"; buttonBar.appendChild(buttonFromFile); buttonBar.appendChild(document.createTextNode(' ')); buttonBar.appendChild(buttonToFile); dbContainer.appendChild(buttonBar); // setup events buttonFromFile.addEventListener('click', () => { utl.LoadFromFile(".json").then((str) => { let parsedObj = {}; try { parsedObj = JSON.parse(str); } catch (e) { console.log(`Could not parse JSON: ${e}`); console.log(e); } for (let slot = 0; slot < 5; ++slot) { let mdi = mdiList[slot]; let slotName = `slot${slot + 1}`; let msgDefName = `${slotName}MsgDef`; if (parsedObj[msgDefName] !== undefined) { // set value directly mdi.SetMsgDefinition(parsedObj[msgDefName]); // trigger save to tracker mdi.GetOnApplyCallback()(); } } }); }); buttonToFile.addEventListener('click', () => { let fileName = `Dashboard_Configuration.json`; let jsonObj = {}; for (let slot = 0; slot < 5; ++slot) { let mdi = mdiList[slot]; let slotName = `slot${slot + 1}`; let msgDefName = `${slotName}MsgDef`; let jsName = `${slotName}JavaScript`; jsonObj[msgDefName] = mdi.GetMsgDefinition(); } let value = JSON.stringify(jsonObj, null, " "); utl.SaveToFile(value, fileName); }); return [container, button]; } #MakeBandInput() { let bandList = [ "2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "70cm", "23cm", ]; let select = document.createElement('select'); for (let band of bandList) { let option = document.createElement('option'); option.value = band; if (band == "2190m") { option.innerHTML = `${band} (HF)`; } else if (band == "630m") { option.innerHTML = `${band} (MF)`; } else { option.innerHTML = band; } select.appendChild(option); } select.value = "20m"; let label = document.createElement('label'); label.innerHTML = "Band "; label.appendChild(select); let container = document.createElement('span'); container.appendChild(label); return [container, select]; } #MakeChannelInput() { let input = document.createElement('input'); input.type = "number"; input.min = 0; input.max = 599; input.title = "Optional, use if you have a Channel-aware tracker" let label = document.createElement('label'); label.innerHTML = "Channel "; label.appendChild(input); let container = document.createElement('span'); container.title = input.title; container.appendChild(label); input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); input.addEventListener("keydown", e => this.#NoSpaces(e)); return [container, input]; } #MakeCallsignInput() { let input = document.createElement('input'); input.title = "callsign"; input.placeholder = "callsign"; input.size = "7"; input.style.textTransform = "uppercase"; let label = document.createElement('label'); label.innerHTML = "Callsign "; label.appendChild(input); let container = document.createElement('span'); container.title = input.title; container.appendChild(label); input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); input.addEventListener("keydown", e => this.#NoSpaces(e)); return [container, input]; } #MakeSearchButtonInput() { let button = document.createElement('button'); button.innerHTML = "search"; return [button, button]; } #MakeSpinner() { // Create the main spinner container const spinnerContainer = document.createElement('div'); spinnerContainer.className = 'spinner-container'; // Create the spinner element const spinner = document.createElement('div'); spinner.className = 'spinner'; // Append spinner to the container spinnerContainer.appendChild(spinner); // Add CSS styles dynamically const style = document.createElement('style'); style.textContent = ` .spinner-container { display: inline-flex; justify-content: center; align-items: center; position: relative; width: 12px; height: 12px; } .spinner { width: 9px; height: 9px; border: 2px solid #f3f3f3; /* Light gray */ border-top: 2px solid #3498db; /* Blue */ border-radius: 50%; animation: spin 1.5s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; document.head.appendChild(style); // Return the spinner container return [spinnerContainer, spinner]; } #MakeGteInput() { let input = document.createElement('input'); input.placeholder = "YYYY-MM-DD"; input.required = true; input.title = "Start date (required)" input.pattern = "\d{4}-\d{2}-\d{2}"; input.spellcheck = false; input.size = "10"; input.maxLength = "10"; let label = document.createElement('label'); label.innerHTML = "Start "; label.appendChild(input); let container = document.createElement('span'); container.title = input.title; container.appendChild(label); input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); input.addEventListener("keydown", e => this.#NoSpaces(e)); return [container, input]; } #MakeLteInput() { let input = document.createElement('input'); input.placeholder = "YYYY-MM-DD"; input.required = true; input.title = "End date (optional)" input.pattern = "\d{4}-\d{2}-\d{2}"; input.spellcheck = false; input.size = "10"; input.maxLength = "10"; let label = document.createElement('label'); label.innerHTML = "End "; label.appendChild(input); let container = document.createElement('span'); container.title = input.title; container.appendChild(label); input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); input.addEventListener("keydown", e => this.#NoSpaces(e)); return [container, input]; } #MakeUI() { this.domContainer = document.createElement('span'); // create let [buttonConfigContainer, buttonConfig] = this.#MakeConfigurationButtonInput("user"); let [buttonConfigProContainer, buttonProConfig] = this.#MakeConfigurationButtonInput("vendor"); let [bandSelectInputContainer, bandSelectInput] = this.#MakeBandInput(); let [channelInputContainer, channelInput] = this.#MakeChannelInput(); let [callsignInputContainer, callsignInput] = this.#MakeCallsignInput(); let [buttonInputContainer, buttonInput] = this.#MakeSearchButtonInput(); let [spinnerContainer, spinner] = this.#MakeSpinner(); let [gteInputContainer, gteInput] = this.#MakeGteInput(); let [lteInputContainer, lteInput] = this.#MakeLteInput(); this.statusUpdated = document.createElement('span'); this.statusLastSeen = document.createElement('span'); // keep the spinner paused to start spinner.style.animationPlayState = "paused"; // assemble let container = document.createElement('span'); container.appendChild(buttonConfigContainer); container.appendChild(document.createTextNode(" ")); container.appendChild(buttonConfigProContainer); buttonConfigProContainer.appendChild(document.createTextNode(" ")); container.appendChild(bandSelectInputContainer); container.appendChild(document.createTextNode(" ")); container.appendChild(channelInputContainer); container.appendChild(document.createTextNode(" ")); container.appendChild(callsignInputContainer); container.appendChild(document.createTextNode(" ")); container.appendChild(buttonInputContainer); container.appendChild(document.createTextNode(" ")); container.appendChild(spinnerContainer); container.appendChild(document.createTextNode(" ")); container.appendChild(gteInputContainer); container.appendChild(document.createTextNode(" ")); container.appendChild(lteInputContainer); container.appendChild(document.createTextNode(" | ")); container.appendChild(this.statusUpdated); container.appendChild(document.createTextNode(" | ")); container.appendChild(this.statusLastSeen); // styling buttonConfigProContainer.style.display = "none"; // events document.addEventListener("keydown", (e) => { let retVal = true; let keyLower = String(e.key || "").toLowerCase(); if (e.key == "?" && e.target == document.body) { e.preventDefault(); retVal = false; if (buttonConfigProContainer.style.display == "none") { buttonConfigProContainer.style.display = ""; } else { buttonConfigProContainer.style.display = "none"; } } else if (keyLower == "r" && e.target == document.body) { e.preventDefault(); retVal = false; // Match the timer refresh behavior exactly. this.#Search(); } return retVal; }); // capture this.buttonConfig = buttonConfig; this.bandSelect = bandSelectInput; this.channelInput = channelInput; this.callsignInput = callsignInput; this.buttonInput = buttonInput; this.spinner = spinner; this.gteInput = gteInput; this.lteInput = lteInput; this.ui = container; return this.ui; } }