1377 lines
41 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
|
|
|