Files
protoloon/js/WsprSearchUiInputController.js
2026-04-02 17:39:02 -06:00

1377 lines
41 KiB
JavaScript

/*
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;
}
}