Add HTML/JS visualizer with plots and graphs

This commit is contained in:
Simon Ruderich 2024-05-11 10:07:44 +02:00
parent 5fb5a2908d
commit 6fc4df205c
9 changed files with 498 additions and 0 deletions

1
impl/utils/visualizer/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/visualizer

View File

@ -0,0 +1,7 @@
all:
go build
clean:
rm -f visualizer
.PHONY: all clean

View File

@ -0,0 +1,5 @@
module visualizer
go 1.21
require golang.org/x/net v0.25.0

View File

@ -0,0 +1,2 @@
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=

View File

@ -0,0 +1,125 @@
// Visualize debug information from hamnet70.
//
// This program is a HTTP server which reads JSON messages (one per line) from
// a FIFO filled by hamnet70 and passes them to the browser via WebSocket.
// Concurrent browser sessions and reconnects are supported. This binary is
// standalone and can be used without the static/ directory.
package main
import (
"bufio"
"embed"
"io/fs"
"log"
"net/http"
"os"
"sync"
"time"
"golang.org/x/net/websocket"
)
//go:embed static/*
var static embed.FS
func main() {
if len(os.Args) != 3 {
log.SetFlags(0)
log.Fatalf("usage: %s <fifo> <json-dup-prefix>", os.Args[0])
}
var mutex sync.Mutex
listeners := make(map[*chan string]struct{})
go func() {
for {
path := os.Args[1]
dupPath := os.Args[2] + "." + time.Now().Format(time.RFC3339)
fh, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
dupFh, err := os.Create(dupPath)
if err != nil {
log.Fatal(err)
}
r := bufio.NewReader(fh)
for {
line, err := r.ReadString('\n')
if err != nil {
log.Printf("read %q: %v", path, err)
break
}
// Duplicate JSON input to a file so it can be replayed later
// if necessary
_, err = dupFh.WriteString(line)
if err != nil {
log.Fatalf("write %q: %v", dupPath, err)
}
// Send to all listeners
mutex.Lock()
for x := range listeners {
*x <- line
}
mutex.Unlock()
}
err = fh.Close()
if err != nil {
log.Fatalf("close %q: %v", path, err)
}
err = dupFh.Close()
if err != nil {
log.Fatalf("close %q: %v", dupPath, err)
}
}
}()
// Use existing "./static/" directory for quick changes
var staticFS http.FileSystem
_, err := os.Stat("static")
if err == nil {
staticFS = http.Dir("static")
} else {
x, err := fs.Sub(static, "static")
if err != nil {
log.Fatal(err)
}
staticFS = http.FS(x)
}
http.Handle("/", http.FileServer(staticFS))
http.Handle("/events", websocket.Handler(func(conn *websocket.Conn) {
// Register new listener for this connection
ch := make(chan string, 512) // buffer up to x number of messages
mutex.Lock()
listeners[&ch] = struct{}{}
mutex.Unlock()
defer func() {
mutex.Lock()
delete(listeners, &ch)
mutex.Unlock()
}()
for {
x := <-ch
err := websocket.Message.Send(conn, x)
if err != nil {
log.Printf("Websocket error: %v", err)
break
}
}
}))
err = http.ListenAndServe("localhost:8080", nil)
if err != nil {
log.Fatal(err)
}
}
// vi: set noet ts=4 sw=4 sts=4:

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,25 @@
#error {
font-weight: bold;
color: red;
}
#trace {
font-family: monospace;
}
#main {
display: flex;
}
#constellation {
height: 85vh;
width: 30vw;
}
#data {
padding-top: 1em;
padding-right: 1em;
text-wrap: nowrap;
}
#history {
max-height: 80vh;
flex-grow: 1;
}

View File

@ -0,0 +1,42 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hamnet70 Visualizer</title>
<link rel="stylesheet" href="index.css"/>
<script src="chart-v4.4.2.js"></script>
</head>
<body>
<div id="error" hidden></div>
<div>
<input id="pause" type="button" value="">
</div>
<div>
Trace: <span id="trace"></span>
</div>
<div>
Preambles found: <span id="preambles_found"></span>,
Successful decodes: <span id="successful_decodes"></span>,
Header errors: <span id="header_errors"></span>,
Failed decodes: <span id="failed_decodes"></span>
</div>
<div id="main">
<div id="constellation">
<canvas id="constellation-canvas"></canvas>
</div>
<div id="data"></div>
<div id="history">
<canvas id="history-canvas"></canvas>
</div>
</div>
<script src="index.js"></script>
</body>
</html>

View File

@ -0,0 +1,271 @@
const tracePlaceholder = Symbol('trace-placeholder');
const traceEvent = Symbol('trace-event');
const tracePacket = Symbol('trace-packet');
const traceMaxEvents = 200;
const state = {
start: new Date(),
paused: false,
global: {
preambles_found: 0,
successful_decodes: 0,
failed_decodes: 0,
header_errors: 0,
},
};
function updatePause(pause) {
const button = document.querySelector('#pause');
if (pause) {
button.value = 'Continue';
} else {
button.value = 'Pause';
}
state.paused = pause;
}
function updateGlobal() {
for (const key in state.global) {
document.querySelector(`#${key}`).textContent = state.global[key];
}
}
function getTime() {
const x = new Date();
// HH:MM:SS.ms, thanks JavaScript
const hh = ('0' + x.getHours()).slice(-2);
const mm = ('0' + x.getMinutes()).slice(-2);
const ss = ('0' + x.getSeconds()).slice(-2);
const ms = ('00' + x.getMilliseconds()).slice(-3);
return `${hh}:${mm}:${ss}.${ms}`;
}
function addTrace(type, data) {
const span = document.createElement('span');
span.textContent = data.string;
span.style.color = data.color;
span.style.cursor = 'default';
if (type !== tracePlaceholder) {
span.title = getTime();
}
if (type === tracePacket) {
span.onclick = () => {
updatePause(true);
updateChart(data.packet);
updateData(data.packet);
};
span.style.cursor = 'pointer';
}
const trace = document.querySelector('#trace');
trace.prepend(span);
if (trace.childNodes.length > traceMaxEvents) {
trace.removeChild(trace.lastChild);
}
}
const chartConstellation = new Chart(
document.querySelector('#constellation-canvas'), {
type: 'scatter',
data: {
datasets: [
{
label: 'Preamble',
data: [],
},
{
label: 'Header',
data: [],
},
{
label: 'Data',
data: [],
},
],
},
options: {
animation: false,
aspectRatio: 1,
elements: {
point: {
radius: 1,
},
},
scales: {
x: {
min: -1.5,
max: 1.5,
},
y: {
min: -1.5,
max: 1.5,
},
},
},
});
const chartHistory = new Chart(
document.querySelector('#history-canvas'), {
type: 'line',
data: {
labels: [],
datasets: [],
},
options: {
animation: false,
},
});
function updateChart(packet) {
chartConstellation.data.datasets[0].data = packet['preamble_symbols'];
chartConstellation.data.datasets[1].data = packet['header_symbols'];
chartConstellation.data.datasets[2].data = packet['data_symbols'];
chartConstellation.update();
}
function updateData(packet) {
const keys = [];
for (const x in packet) {
if (x === 'preamble_symbols'
|| x === 'header_symbols'
|| x === 'data_symbols') {
continue
}
keys.push(x);
}
keys.sort();
const divs = [];
for (const x of keys) {
const div = document.createElement('div');
div.textContent = `${x}: ${packet[x]}`;
divs.push(div);
}
document.querySelector('#data').replaceChildren(...divs);
}
function addHistory(packet) {
const keys = [];
for (const x in packet) {
if (x === 'preamble_symbols'
|| x === 'header_symbols'
|| x === 'data_symbols') {
continue;
}
if (x === 'header_evm' || x === 'data_evm') {
continue;
};
keys.push(x);
}
keys.sort();
if (chartHistory.data.datasets.length === 0) {
for (const x of keys) {
chartHistory.data.datasets.push({
label: x,
data: [],
})
}
}
let i = 0;
for (const x of keys) {
chartHistory.data.datasets[i].data.push(packet[x]);
i++;
}
chartHistory.data.labels.push((new Date() - state.start)/1000);
chartHistory.update();
}
const ws = new WebSocket(location.origin.replace(/^http/, 'ws') + '/events');
ws.onmessage = (evt) => {
if (state.paused) {
return;
}
const msg = JSON.parse(evt.data);
if ('preambles_found' in msg) {
const old = {};
for (const x in state.global) {
old[x] = state.global[x];
}
for (const x in msg) {
state.global[x] = msg[x];
old[x] = msg[x] - old[x]; // delta
}
updateGlobal();
// Update trace with new events
for (const x in old) {
if (old[x] === 0) {
continue;
}
let string, color;
if (x === 'preambles_found') {
string = 'p';
color = 'gray';
} else if (x === 'successful_decodes') {
// Nothing to do as packet will be added to trace
continue;
} else if (x === 'header_errors') {
string = 'H';
color = 'red';
} else if (x === 'failed_decodes') {
string = 'D';
color = 'red';
} else {
console.log(`Unknown event "${x}"!`);
continue;
}
addTrace(traceEvent, {
string: string,
color: color,
});
}
} else if ('data_symbols' in msg) {
updateChart(msg);
updateData(msg);
addHistory(msg);
addTrace(tracePacket, {
string: 'P',
color: 'black',
packet: msg,
});
}
};
ws.onclose = (evt) => {
const div = document.querySelector('#error');
div.textContent = 'Websocket connection closed!';
div.hidden = false;
// Log details
console.log(evt);
};
ws.onerror = ws.onclose;
{
const pause = document.querySelector('#pause');
pause.onclick = () => {
updatePause(!state.paused);
}
updatePause(false);
// Initialize trace
for (let i = 0; i < traceMaxEvents; i++) {
addTrace(tracePlaceholder, {
string: '.',
color: 'lightgray',
});
}
updateGlobal();
}