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