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

898 lines
27 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 { DialogBox } from './DomWidgets.js';
import { StrAccumulator } from '/js/Utl.js';
import { WsprCodecMaker } from '../../../../pro/codec/WsprCodec.js';
export class MsgDefinitionInputUiController
{
constructor()
{
this.codecMaker = new WsprCodecMaker();
this.onApplyCbFn = () => {};
this.onErrCbFn = () => {};
this.ok = true;
this.cachedLastMsgDefApplied = "";
this.namePrefix = "Message Definition Analysis";
this.name = "";
this.fileNamePart = "";
this.ui = this.#MakeUI();
this.#SetUpEvents();
this.#ShowExampleValue();
}
SetDisplayName(name)
{
this.name = name;
this.dialogBox.SetTitleBar(this.namePrefix + (this.name == "" ? "" : ` - ${this.name}`));
}
SetDownloadFileNamePart(fileNamePart)
{
this.fileNamePart = fileNamePart;
}
GetUI()
{
return this.ui;
}
GetUIInput()
{
return this.msgDefInput;
}
GetUIAnalysis()
{
return this.codecAnalysis;
}
GetUIButtonApply()
{
return this.applyButton;
}
GetUIButtonRestore()
{
return this.restoreButton;
}
GetUIButtonShowExample()
{
return this.showExampleButton;
}
GetUIButtonFromFile()
{
return this.uploadButton;
}
GetUIButtonPrettify()
{
return this.prettifyButton;
}
GetUIButtonToFile()
{
return this.downloadButton;
}
PrettifyMsgDefinition()
{
let prettyText = this.#BuildPrettifiedMsgDefinitionText();
if (!prettyText)
{
return false;
}
let wasApplied = this.GetMsgDefinitionRaw() == this.cachedLastMsgDefApplied;
this.msgDefInput.value = prettyText;
this.#OnMsgDefInputChange();
if (this.ok && wasApplied)
{
this.cachedLastMsgDefApplied = prettyText;
this.#MarkMsgDefApplied();
this.#SetStateApplied();
this.onApplyCbFn();
}
this.onErrCbFn(this.ok);
return this.ok;
}
SetModeNoPopup()
{
// remove show/hide button
this.ui.removeChild(this.analysisButton);
// remove dialog box
this.ui.removeChild(this.dialogBox.GetUI());
// insert analysis
this.codecAnalysis.style.marginTop = "3px";
this.ui.append(this.codecAnalysis);
return this.ui;
}
SetModeIndividual()
{
// git rid of styling which doesn't apply
this.msgDefInput.style.marginBottom = "0px";
// remove show/hide button
this.ui.removeChild(this.analysisButton);
// remove dialog box
this.ui.removeChild(this.dialogBox.GetUI());
}
SetOnApplyCallback(cb)
{
this.onApplyCbFn = cb;
}
GetOnApplyCallback()
{
return this.onApplyCbFn;
}
SetOnErrStateChangeCallback(cb)
{
this.onErrCbFn = cb;
}
IsOk()
{
return this.ok;
}
GetMsgDefinition()
{
return this.cachedLastMsgDefApplied;
}
GetMsgDefinitionRaw()
{
return this.msgDefInput.value;
}
GetFieldList()
{
let c = this.codecMaker.GetCodecInstance();
const fieldList = c.GetFieldList();
return fieldList;
}
GetFieldNameList()
{
const fieldList = this.GetFieldList();
let fieldNameList = [];
for (let field of fieldList)
{
fieldNameList.push(`${field.name}${field.unit}`);
}
return fieldNameList;
}
SetMsgDefinition(value, markApplied)
{
markApplied = markApplied ?? true;
this.msgDefInput.value = value;
this.#OnMsgDefInputChange();
if (this.ok)
{
if (markApplied)
{
this.cachedLastMsgDefApplied = value;
this.#MarkMsgDefApplied();
this.#SetStateApplied();
}
}
else
{
// it's bad, so indicate that whatever the prior applied value
// was is still in effect
this.#DisableApplyButton();
}
this.onErrCbFn(this.ok);
return this.ok;
}
#SetUpEvents()
{
this.msgDefInput.addEventListener('input', () => {
this.#OnMsgDefInputChange();
})
this.applyButton.addEventListener('click', () => {
if (this.ok)
{
this.cachedLastMsgDefApplied = this.GetMsgDefinitionRaw();
this.#MarkMsgDefApplied();
this.#SetStateApplied();
this.onApplyCbFn();
}
});
this.restoreButton.addEventListener('click', () => {
this.SetMsgDefinition(this.cachedLastMsgDefApplied, false);
});
this.showExampleButton.addEventListener('click', () => {
this.#ShowExampleValue();
this.#OnMsgDefInputChange();
});
this.uploadButton.addEventListener('click', () => {
utl.LoadFromFile(".json").then((str) => {
this.SetMsgDefinition(str, false);
});
});
this.prettifyButton.addEventListener('click', () => {
this.PrettifyMsgDefinition();
});
this.downloadButton.addEventListener('click', () => {
let fileName = `MsgDef`;
if (this.fileNamePart != "")
{
fileName += `_`;
fileName += this.fileNamePart;
}
fileName += `.json`;
utl.SaveToFile(this.GetMsgDefinitionRaw(), fileName);
});
this.analysisButton.addEventListener('click', () => {
this.dialogBox.ToggleShowHide();
});
utl.GiveHotkeysVSCode(this.msgDefInput, () => {
this.applyButton.click();
});
}
GetExampleValue()
{
let msgDefRowList = [
`// Example Message Definition -- modify then save!\n`,
`{ "name": "Altitude", "unit": "Meters", "lowValue": 0, "highValue": 21340, "stepSize": 20 },`,
`{ "name": "SatsUSA", "unit": "Count", "lowValue": 0, "highValue": 32, "stepSize": 4 },`,
`{ "name": "ADC1", "unit": "Volts", "lowValue": 2.5, "highValue": 5.5, "stepSize": 0.2 },`,
`{ "name": "SomeInteger", "unit": "Value", "lowValue": -10, "highValue": 110, "stepSize": 5 },`,
`{ "name": "SomeFloat", "unit": "Value", "lowValue": -10.5, "highValue": 9.5, "stepSize": 20 },`,
`{ "name": "ClockDrift", "unit": "Millis", "valueSegmentList": [[-25, 5, -5], [-5, 1, 5], [5, 5, 25]] },`,
];
let str = ``;
let sep = "";
for (let msgDefRow of msgDefRowList)
{
str += sep;
str += msgDefRow;
sep = "\n";
}
return str;
}
#ShowExampleValue()
{
this.SetMsgDefinition(this.GetExampleValue(), false);
}
#OnMsgDefInputChange()
{
this.ok = this.#ApplyMsgDefinition();
// handle setting the validity state
if (this.ok)
{
this.#MarkMsgDefValid();
// handle setting the applied state
// (this can override the msg def coloring)
if (this.GetMsgDefinitionRaw() == this.cachedLastMsgDefApplied)
{
this.#SetStateApplied();
}
else
{
this.#SetStateNotApplied();
}
}
else
{
this.#MarkMsgDefInvalid();
this.#DisableApplyButton();
}
this.onErrCbFn(this.ok);
return this.ok;
}
#MarkMsgDefValid()
{
this.msgDefInput.style.backgroundColor = "rgb(235, 255, 235)";
this.restoreButton.disabled = false;
}
#MarkMsgDefInvalid()
{
this.msgDefInput.style.backgroundColor = "lightpink";
this.restoreButton.disabled = false;
}
#MarkMsgDefApplied()
{
this.msgDefInput.style.backgroundColor = "white";
this.restoreButton.disabled = true;
}
#DisableApplyButton()
{
this.applyButton.disabled = true;
}
#SetStateApplied()
{
this.#DisableApplyButton();
this.restoreButton.disabled = false;
this.#MarkMsgDefApplied();
}
#SetStateNotApplied()
{
this.applyButton.disabled = false;
}
#CheckMsgDefOk()
{
let ok = this.codecMaker.SetCodecDefFragment("MyMessageType", this.msgDefInput.value);
return ok;
}
#ApplyMsgDefinition()
{
let ok = this.#CheckMsgDefOk();
ok &= this.#DoMsgDefinitionAnalysis(ok);
return ok;
}
#DoMsgDefinitionAnalysis(codecOk)
{
let retVal = true;
if (codecOk)
{
// get msg data
const fieldList = this.codecMaker.GetCodecInstance().GetFieldList();
// calc max field length for formatting
let maxFieldName = 5;
for (let field of fieldList)
{
let fieldName = field.name + field.unit;
if (fieldName.length > maxFieldName)
{
maxFieldName = fieldName.length;
}
}
// analyze utilization
let sumBits = 0;
for (let field of fieldList)
{
sumBits += field.Bits;
}
// output
const ENCODABLE_BITS = this.codecMaker.GetFieldBitsAvailable();
let pctUsed = (sumBits * 100 / ENCODABLE_BITS);
let pctUsedErr = "";
if (sumBits > ENCODABLE_BITS)
{
retVal = false;
pctUsedErr = "<---- OVERFLOW ERR";
}
let bitsRemaining = ENCODABLE_BITS - sumBits;
if (bitsRemaining < 0) { bitsRemaining = 0; }
let pctRemaining = (bitsRemaining * 100 / ENCODABLE_BITS);
// determine the number of values that could be encoded in the remaining bits, if any
let values = Math.pow(2, bitsRemaining);
if (bitsRemaining < 1)
{
values = 0;
}
let valuesFloor = Math.floor(values);
// setTimeout(() => {
// console.log(`------`)
// for (let field of fieldList)
// {
// console.log(`${field.name}${field.unit}: ${field.Bits} bits`);
// }
// console.log(`Encodable bits: ${ENCODABLE_BITS}`);
// console.log(`Sum bits: ${sumBits}`);
// console.log(`Bits remaining: ${bitsRemaining}`);
// console.log(`Values that could be encoded in remaining bits: ${values}`);
// console.log(`Values (floor) that could be encoded in remaining bits: ${valuesFloor}`);
// }, 0);
let valuesStr = ` (${utl.Commas(0).padStart(11)} values)`;
if (bitsRemaining >= 1)
{
valuesStr = ` (${utl.Commas(valuesFloor).padStart(11)} values)`;
}
// put out to 3 decimal places because available bits is 29.180... and so
// no need to worry about rounding after the 29.180 portion, so just display
// it and move on.
let a = new StrAccumulator();
let valuesAvailable = utl.Commas(Math.floor(Math.pow(2, ENCODABLE_BITS)));
a.A(`Encodable Bits Available: ${ENCODABLE_BITS.toFixed(3).padStart(6)} (${valuesAvailable.padStart(6)} values)`);
a.A(`Encodable Bits Used : ${sumBits.toFixed(3).padStart(6)} (${pctUsed.toFixed(2).padStart(6)} %) ${pctUsedErr}`);
a.A(`Encodable Bits Remaining: ${(bitsRemaining).toFixed(3).padStart(6)} (${pctRemaining.toFixed(2).padStart(6)} %)${valuesStr}`);
let PAD_VALUES = 9;
let PAD_BITS = 6;
let PAD_AVAIL = 8;
let FnOutput = (name, numValues, numBits, pct) => {
a.A(`${name.padEnd(maxFieldName)} ${numValues.padStart(PAD_VALUES)} ${numBits.padStart(PAD_BITS)} ${pct.padStart(PAD_AVAIL)}`);
}
a.A(``);
FnOutput("Field", "# Values", "# Bits", "% Used");
a.A(`-`.repeat(maxFieldName) + `-`.repeat(PAD_VALUES) + `-`.repeat(PAD_BITS) + `-`.repeat(PAD_AVAIL) + `-`.repeat(9));
let fieldRowList = [];
for (let field of fieldList)
{
let fieldName = field.name + field.unit;
let pct = (field.Bits * 100 / ENCODABLE_BITS).toFixed(2);
fieldRowList.push({
field,
fieldJsonText: this.#GetRawFieldJsonText(field),
fieldName,
numValues: field.NumValues.toString(),
bits: field.Bits.toFixed(3).toString(),
pct,
});
}
this.#SetCodecAnalysisWithFieldRows(a.Get(), fieldRowList, maxFieldName, PAD_VALUES, PAD_BITS, PAD_AVAIL);
}
else
{
retVal = false;
let a = new StrAccumulator();
a.A(`Codec definition invalid. (Make sure all rows have a trailing comma)`);
a.A(``);
for (let err of this.codecMaker.GetErrList())
{
a.A(err);
}
this.#SetCodecAnalysisPlain(a.Get());
}
return retVal;
}
#SetCodecAnalysisPlain(text)
{
this.codecAnalysis.replaceChildren(document.createTextNode(text));
}
#SetCodecAnalysisWithFieldRows(prefixText, fieldRowList, maxFieldName, PAD_VALUES, PAD_BITS, PAD_AVAIL)
{
this.codecAnalysis.replaceChildren();
this.codecAnalysis.appendChild(document.createTextNode(prefixText));
for (let row of fieldRowList)
{
let line = document.createElement("div");
line.style.whiteSpace = "pre";
line.style.fontFamily = "inherit";
line.style.fontSize = "inherit";
line.style.lineHeight = "inherit";
let fieldNamePadding = " ".repeat(Math.max(0, maxFieldName - row.fieldName.length));
let suffix = ` ${row.numValues.padStart(PAD_VALUES)} ${row.bits.padStart(PAD_BITS)} ${row.pct.padStart(PAD_AVAIL)}`;
let link = document.createElement("a");
link.href = this.#GetSegmentedFieldCalculatorUrl(row.field, row.fieldJsonText);
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = row.fieldName;
link.style.fontFamily = "inherit";
link.style.fontSize = "inherit";
link.style.lineHeight = "inherit";
link.style.display = "inline";
line.appendChild(link);
line.appendChild(document.createTextNode(fieldNamePadding));
line.appendChild(document.createTextNode(suffix));
this.codecAnalysis.appendChild(line);
}
}
#NormalizeFieldJsonForCompare(field)
{
if (!field || typeof field !== "object")
{
return null;
}
let normalized = {
name: String(field.name ?? "").trim(),
unit: String(field.unit ?? "").trim(),
};
if (Array.isArray(field.valueSegmentList))
{
normalized.valueSegmentList = field.valueSegmentList.map((segment) => Array.isArray(segment) ? segment.map((value) => Number(value)) : segment);
}
else
{
normalized.lowValue = Number(field.lowValue);
normalized.highValue = Number(field.highValue);
normalized.stepSize = Number(field.stepSize);
}
return JSON.stringify(normalized);
}
#GetRawFieldJsonText(field)
{
const target = this.#NormalizeFieldJsonForCompare(field);
if (!target)
{
return "";
}
const lineList = this.GetMsgDefinitionRaw().split("\n");
for (const rawLine of lineList)
{
const trimmed = rawLine.trim();
if (!trimmed || trimmed.startsWith("//"))
{
continue;
}
try
{
const parsed = JSON.parse(trimmed.replace(/,\s*$/, ""));
if (this.#NormalizeFieldJsonForCompare(parsed) === target)
{
return trimmed.replace(/,\s*$/, "");
}
}
catch
{
// Ignore non-JSON lines.
}
}
return "";
}
#GetSegmentedFieldCalculatorUrl(field, fieldJsonText = "")
{
let fieldJson = Array.isArray(field?.valueSegmentList)
? {
name: field.name,
unit: field.unit,
valueSegmentList: field.valueSegmentList,
}
: {
name: field.name,
unit: field.unit,
lowValue: field.lowValue,
highValue: field.highValue,
stepSize: field.stepSize,
};
const exactFieldJsonText = fieldJsonText || this.#GetRawFieldJsonText(field);
return `/pro/codec/fieldcalc/?fieldJson=${encodeURIComponent(exactFieldJsonText || JSON.stringify(fieldJson))}`;
}
#ParsePrettifyFieldRowList()
{
let fieldRowList = [];
for (let rawLine of this.GetMsgDefinitionRaw().split("\n"))
{
let trimmed = rawLine.trim();
if (!trimmed || trimmed.startsWith("//"))
{
continue;
}
try
{
let parsed = JSON.parse(trimmed.replace(/,\s*$/, ""));
if (!parsed || typeof parsed != "object" || Array.isArray(parsed))
{
continue;
}
if (typeof parsed.name != "string" || typeof parsed.unit != "string")
{
continue;
}
if (Array.isArray(parsed.valueSegmentList))
{
fieldRowList.push({
type: "segmented",
name: parsed.name,
unit: parsed.unit,
valueSegmentList: parsed.valueSegmentList,
});
}
else if (typeof parsed.lowValue == "number" && typeof parsed.highValue == "number" && typeof parsed.stepSize == "number")
{
fieldRowList.push({
type: "uniform",
name: parsed.name,
unit: parsed.unit,
lowValue: parsed.lowValue,
highValue: parsed.highValue,
stepSize: parsed.stepSize,
});
}
}
catch
{
return [];
}
}
return fieldRowList;
}
#FormatSegmentListOneLine(valueSegmentList)
{
return `[${valueSegmentList
.map((segment) => `[${segment.map((value) => Number(value).toString()).join(", ")}]`)
.join(", ")}]`;
}
#BuildAlignedFieldPart(key, valueText, keyWidth, valueWidth = 0, align = "left", withComma = true, padBeforeComma = true)
{
let keyText = `"${key}":`;
let rawValueText = String(valueText);
let finalValueText = rawValueText;
if (valueWidth > 0)
{
if (align == "right" || padBeforeComma)
{
finalValueText = align == "right"
? rawValueText.padStart(valueWidth)
: rawValueText.padEnd(valueWidth);
}
}
if (align == "left" && valueWidth > 0 && padBeforeComma == false)
{
let textWithComma = `${rawValueText}${withComma ? "," : ""}`;
return `${keyText} ${textWithComma.padEnd(valueWidth + (withComma ? 1 : 0))}`;
}
return `${keyText} ${finalValueText}${withComma ? "," : ""}`;
}
#BuildPrettifiedMsgDefinitionText()
{
if (this.#CheckMsgDefOk() == false)
{
return "";
}
let fieldRowList = this.#ParsePrettifyFieldRowList();
if (!fieldRowList.length)
{
return "";
}
let nameValueList = fieldRowList.map((field) => JSON.stringify(field.name));
let unitValueList = fieldRowList.map((field) => JSON.stringify(field.unit));
let lowValueList = fieldRowList.filter((field) => field.type == "uniform").map((field) => Number(field.lowValue).toString());
let highValueList = fieldRowList.filter((field) => field.type == "uniform").map((field) => Number(field.highValue).toString());
let stepValueList = fieldRowList.filter((field) => field.type == "uniform").map((field) => Number(field.stepSize).toString());
let maxNameValueWidth = Math.max(...nameValueList.map((part) => part.length));
let maxUnitValueWidth = Math.max(...unitValueList.map((part) => part.length));
let maxLowValueWidth = lowValueList.length ? Math.max(...lowValueList.map((part) => part.length)) : 0;
let maxHighValueWidth = highValueList.length ? Math.max(...highValueList.map((part) => part.length)) : 0;
let maxStepValueWidth = stepValueList.length ? Math.max(...stepValueList.map((part) => part.length)) : 0;
let maxFirstKeyWidth = Math.max(`"name":`.length, `"unit":`.length);
let namePartWidth = Math.max(...nameValueList.map((value) => this.#BuildAlignedFieldPart("name", value, maxFirstKeyWidth, maxNameValueWidth, "left", true, false).length));
let unitPartWidth = Math.max(...unitValueList.map((value) => this.#BuildAlignedFieldPart("unit", value, maxFirstKeyWidth, maxUnitValueWidth, "left", true, false).length));
let lineBodyList = fieldRowList.map((field, index) => {
let namePart = this.#BuildAlignedFieldPart("name", nameValueList[index], maxFirstKeyWidth, maxNameValueWidth, "left", true, false).padEnd(namePartWidth);
let unitPart = this.#BuildAlignedFieldPart("unit", unitValueList[index], maxFirstKeyWidth, maxUnitValueWidth, "left", true, false).padEnd(unitPartWidth);
if (field.type == "segmented")
{
let segmentListText = this.#FormatSegmentListOneLine(field.valueSegmentList);
let thirdPart = this.#BuildAlignedFieldPart("valueSegmentList", segmentListText, 0, 0, "left", false);
return `{ ${namePart} ${unitPart} ${thirdPart}`;
}
let thirdPart = this.#BuildAlignedFieldPart("lowValue", Number(field.lowValue).toString(), 0, maxLowValueWidth, "right", true);
let fourthPart = this.#BuildAlignedFieldPart("highValue", Number(field.highValue).toString(), 0, maxHighValueWidth, "right", true);
let fifthPart = this.#BuildAlignedFieldPart("stepSize", Number(field.stepSize).toString(), 0, maxStepValueWidth, "right", false);
return `{ ${namePart} ${unitPart} ${thirdPart} ${fourthPart} ${fifthPart}`;
});
let maxBodyWidth = Math.max(...lineBodyList.map((line) => line.length));
let finalLineList = lineBodyList.map((line) => `${line.padEnd(maxBodyWidth)} },`);
return finalLineList.join("\n");
}
#MakeUI()
{
// main ui
let ui = document.createElement('div');
ui.style.boxSizing = "border-box";
// ui.style.border = "3px solid red";
// input for msg definitions
this.msgDefInput = this.#MakeMsgDefInput();
this.msgDefInput.style.marginBottom = "3px";
ui.appendChild(this.msgDefInput);
// make apply button
this.applyButton = document.createElement('button');
this.applyButton.innerHTML = "Apply";
ui.appendChild(this.applyButton);
ui.appendChild(document.createTextNode(' '));
// make restore last button
this.restoreButton = document.createElement('button');
this.restoreButton.innerHTML = "Restore Last Applied";
ui.appendChild(this.restoreButton);
ui.appendChild(document.createTextNode(' '));
// make show example button
this.showExampleButton = document.createElement('button');
this.showExampleButton.innerHTML = "Show Example";
ui.appendChild(this.showExampleButton);
ui.appendChild(document.createTextNode(' '));
// button to prettify the msg def
this.prettifyButton = document.createElement('button');
this.prettifyButton.innerHTML = "Prettify";
ui.appendChild(this.prettifyButton);
ui.appendChild(document.createTextNode(' '));
// button to upload a msg def json file
this.uploadButton = document.createElement('button');
this.uploadButton.innerHTML = "From File";
ui.appendChild(this.uploadButton);
ui.appendChild(document.createTextNode(' '));
// button to download the msg def into a json file
this.downloadButton = document.createElement('button');
this.downloadButton.innerHTML = "To File";
ui.appendChild(this.downloadButton);
ui.appendChild(document.createTextNode(' '));
// button to show/hide msg def analysis
this.analysisButton = document.createElement('button');
this.analysisButton.innerHTML = "Show/Hide Analysis";
ui.appendChild(this.analysisButton);
// msg def analysis
this.codecAnalysis = this.#MakeCodecAnalysis();
// dialog for showing msg def analysis
this.dialogBox = new DialogBox();
ui.appendChild(this.dialogBox.GetUI());
this.dialogBox.SetTitleBar(this.namePrefix + (this.name == "" ? "" : ` - ${this.name}`));
this.dialogBox.GetContentContainer().appendChild(this.codecAnalysis);
return ui;
}
#MakeMsgDefInput()
{
let dom = document.createElement('textarea');
dom.style.boxSizing = "border-box";
dom.spellcheck = false;
dom.style.backgroundColor = "white";
dom.placeholder = "// Message Definition goes here";
// I want it to take up a row by itself
dom.style.display = "block";
dom.style.minWidth = "800px";
dom.style.minHeight = "150px";
return dom;
}
#MakeCodecAnalysis()
{
let dom = document.createElement('div');
dom.style.boxSizing = "border-box";
dom.style.backgroundColor = "rgb(234, 234, 234)";
dom.style.fontFamily = "monospace";
dom.style.whiteSpace = "pre-wrap";
dom.style.overflow = "auto";
dom.style.padding = "2px";
dom.style.border = "1px solid rgb(118, 118, 118)";
dom.style.resize = "both";
dom.style.width = "500px";
dom.style.height = "190px";
dom.style.cursor = "default";
// make it so flex column container sees this as a whole row
dom.style.display = "block";
dom.style.minWidth = "500px";
dom.style.minHeight = "190px";
return dom;
}
}