Add static assets

This commit is contained in:
brentperteet
2026-06-24 11:15:18 -05:00
parent 5703c05c1d
commit 1c60e012d5
3 changed files with 1535 additions and 0 deletions

925
static/app.js Normal file
View File

@@ -0,0 +1,925 @@
const state = {
commands: [],
action: "GET",
services: [],
connected: false,
measurement: {
logging: false,
mode: "live",
file: null,
notes: "",
weather: null,
fixedOnly: false,
totalCount: 0,
allPoints: [],
points: [],
metrics: { count: 0 },
},
};
const EARTH_RADIUS_M = 6371008.8;
const RTK_FIXED_STREAK_LENGTH = 5;
const TREND_METRICS = [
{ key: "rms_m", label: "Measured RMS", digits: 3, suffix: " m" },
{ key: "cep50_m", label: "CEP50", digits: 3, suffix: " m" },
{ key: "r95_m", label: "R95 / CEP95", digits: 3, suffix: " m" },
{ key: "two_drms_m", label: "2DRMS", digits: 3, suffix: " m" },
{ key: "mean_error_m", label: "Mean Error", digits: 3, suffix: " m" },
{ key: "max_error_m", label: "Max Error", digits: 3, suffix: " m" },
{ key: "span_2d_m", label: "Span 2D", digits: 3, suffix: " m" },
{ key: "receiver_hrms_rms_m", label: "Receiver HRMS", digits: 3, suffix: " m" },
{ key: "receiver_vrms_rms_m", label: "Receiver VRMS", digits: 3, suffix: " m" },
{ key: "rms_to_receiver_hrms_ratio", label: "RMS / HRMS", digits: 2, suffix: "x" },
];
const $ = (id) => document.getElementById(id);
function setStatus(connected, detail = "") {
state.connected = connected;
$("statusPill").textContent = connected ? "Online" : "Offline";
$("statusPill").classList.toggle("online", connected);
$("statusText").textContent = detail || (connected ? "Connected" : "Disconnected");
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
message = body.detail || message;
} catch {
// Keep the status text when the response is not JSON.
}
throw new Error(message);
}
return response.json();
}
function log(text, cls = "") {
const terminal = $("terminal");
const span = document.createElement("span");
if (cls) span.className = cls;
span.textContent = text.endsWith("\n") ? text : `${text}\n`;
terminal.appendChild(span);
terminal.scrollTop = terminal.scrollHeight;
}
function option(label, value) {
const item = document.createElement("option");
item.textContent = label;
item.value = value;
return item;
}
function fmt(value, digits = 3, suffix = "") {
if (value == null || Number.isNaN(Number(value))) return "--";
return `${Number(value).toFixed(digits)}${suffix}`;
}
function haversineMeters(lat1, lon1, lat2, lon2) {
const toRad = (value) => (value * Math.PI) / 180;
const lat1Rad = toRad(lat1);
const lat2Rad = toRad(lat2);
const deltaLat = toRad(lat2 - lat1);
const deltaLon = toRad(lon2 - lon1);
const a =
Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(deltaLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
function pointOffset(point, originLat, originLon) {
let east = haversineMeters(originLat, originLon, originLat, point.longitude);
let north = haversineMeters(originLat, originLon, point.latitude, originLon);
if (point.longitude < originLon) east *= -1;
if (point.latitude < originLat) north *= -1;
return { east, north };
}
function rootMeanSquare(values) {
return values.length ? Math.sqrt(values.reduce((sum, value) => sum + value * value, 0) / values.length) : null;
}
function percentile(values, pct) {
if (!values.length) return null;
const ordered = [...values].sort((a, b) => a - b);
if (ordered.length === 1) return ordered[0];
const rank = (ordered.length - 1) * pct;
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return ordered[lower];
return ordered[lower] + (ordered[upper] - ordered[lower]) * (rank - lower);
}
function numericValues(points, key) {
return points.map((point) => Number(point[key])).filter((value) => Number.isFinite(value));
}
function isRtkFixed(point) {
return Number(point.status) === 4;
}
function rtkFixedStreakPoints(points, streakLength = RTK_FIXED_STREAK_LENGTH) {
const selected = [];
let streak = 0;
for (const point of points) {
if (isRtkFixed(point)) {
streak += 1;
if (streak >= streakLength) selected.push(point);
} else {
streak = 0;
}
}
return selected;
}
function filteredMeasurementPoints(points) {
return state.measurement.fixedOnly ? rtkFixedStreakPoints(points) : points;
}
function allMeasurementPoints() {
return state.measurement.allPoints.length ? state.measurement.allPoints : state.measurement.points;
}
function setMeasurementPoints(points) {
state.measurement.allPoints = Array.isArray(points) ? points : [];
state.measurement.points = filteredMeasurementPoints(state.measurement.allPoints);
state.measurement.totalCount = state.measurement.allPoints.length;
}
function refreshMeasurementPointFilter() {
state.measurement.points = filteredMeasurementPoints(allMeasurementPoints());
}
function measurementPoints() {
return state.measurement.points;
}
function measurementMetrics(points) {
if (state.measurement.fixedOnly) return calculatePointMetrics(points);
return enrichReceiverMetrics(state.measurement.metrics || { count: 0 }, points);
}
function calculatePointMetrics(points) {
const validPoints = points.filter((point) => point.latitude != null && point.longitude != null);
const count = validPoints.length;
if (!count) return { count: 0 };
const meanLat = validPoints.reduce((sum, point) => sum + Number(point.latitude), 0) / count;
const meanLon = validPoints.reduce((sum, point) => sum + Number(point.longitude), 0) / count;
const offsets = validPoints.map((point) => pointOffset(point, meanLat, meanLon));
const radialErrors = validPoints.map((point) =>
haversineMeters(meanLat, meanLon, Number(point.latitude), Number(point.longitude))
);
const eastErrors = offsets.map((offset) => offset.east);
const northErrors = offsets.map((offset) => offset.north);
const stdEast = rootMeanSquare(eastErrors);
const stdNorth = rootMeanSquare(northErrors);
const spanEast = Math.max(...eastErrors) - Math.min(...eastErrors);
const spanNorth = Math.max(...northErrors) - Math.min(...northErrors);
const hrmsValues = numericValues(validPoints, "hrms_m");
const vrmsValues = numericValues(validPoints, "vrms_m");
const receiverHrms = rootMeanSquare(hrmsValues);
const rms = rootMeanSquare(radialErrors);
const withinReceiverHrms = validPoints
.map((point, index) => (point.hrms_m == null ? null : radialErrors[index] <= Number(point.hrms_m)))
.filter((value) => value != null);
const statusCounts = {};
for (const point of validPoints) {
const label = String(point.status_text ?? point.status ?? "Unknown");
statusCounts[label] = (statusCounts[label] || 0) + 1;
}
return {
count,
gnpos_count: validPoints.filter((point) => point.source === "gnpos").length,
mean_latitude: meanLat,
mean_longitude: meanLon,
rms_m: rms,
cep50_m: percentile(radialErrors, 0.5),
cep95_m: percentile(radialErrors, 0.95),
r95_m: percentile(radialErrors, 0.95),
drms_m: stdEast != null && stdNorth != null ? Math.sqrt(stdEast * stdEast + stdNorth * stdNorth) : null,
two_drms_m: stdEast != null && stdNorth != null ? 2 * Math.sqrt(stdEast * stdEast + stdNorth * stdNorth) : null,
std_east_m: stdEast,
std_north_m: stdNorth,
mean_error_m: radialErrors.reduce((sum, value) => sum + value, 0) / count,
max_error_m: Math.max(...radialErrors),
span_east_m: spanEast,
span_north_m: spanNorth,
span_2d_m: Math.sqrt(spanEast * spanEast + spanNorth * spanNorth),
receiver_estimate_count: hrmsValues.length,
receiver_hrms_mean_m: hrmsValues.length ? hrmsValues.reduce((sum, value) => sum + value, 0) / hrmsValues.length : null,
receiver_hrms_rms_m: receiverHrms,
receiver_hrms_min_m: hrmsValues.length ? Math.min(...hrmsValues) : null,
receiver_hrms_max_m: hrmsValues.length ? Math.max(...hrmsValues) : null,
receiver_hrms_latest_m: hrmsValues.length ? hrmsValues[hrmsValues.length - 1] : null,
receiver_vrms_mean_m: vrmsValues.length ? vrmsValues.reduce((sum, value) => sum + value, 0) / vrmsValues.length : null,
receiver_vrms_rms_m: rootMeanSquare(vrmsValues),
receiver_vrms_latest_m: vrmsValues.length ? vrmsValues[vrmsValues.length - 1] : null,
rms_minus_receiver_hrms_m: rms != null && receiverHrms != null ? rms - receiverHrms : null,
rms_to_receiver_hrms_ratio: rms != null && receiverHrms ? rms / receiverHrms : null,
within_receiver_hrms_percent: withinReceiverHrms.length
? (withinReceiverHrms.filter(Boolean).length / withinReceiverHrms.length) * 100
: null,
status_counts: statusCounts,
};
}
function enrichReceiverMetrics(metrics, points) {
const enriched = { ...(metrics || {}) };
const validPoints = points.filter((point) => point.latitude != null && point.longitude != null);
if (!validPoints.length) return enriched;
if (enriched.gnpos_count == null) {
enriched.gnpos_count = validPoints.filter((point) => point.source === "gnpos").length;
}
if (!enriched.status_counts) {
enriched.status_counts = {};
for (const point of validPoints) {
const label = String(point.status_text ?? point.status ?? "Unknown");
enriched.status_counts[label] = (enriched.status_counts[label] || 0) + 1;
}
}
const hrmsValues = validPoints.map((point) => Number(point.hrms_m)).filter((value) => Number.isFinite(value));
const vrmsValues = validPoints.map((point) => Number(point.vrms_m)).filter((value) => Number.isFinite(value));
if (hrmsValues.length) {
enriched.receiver_estimate_count = hrmsValues.length;
enriched.receiver_hrms_mean_m ??= hrmsValues.reduce((sum, value) => sum + value, 0) / hrmsValues.length;
enriched.receiver_hrms_rms_m ??= rootMeanSquare(hrmsValues);
enriched.receiver_hrms_min_m ??= Math.min(...hrmsValues);
enriched.receiver_hrms_max_m ??= Math.max(...hrmsValues);
enriched.receiver_hrms_latest_m ??= hrmsValues[hrmsValues.length - 1];
}
if (vrmsValues.length) {
enriched.receiver_vrms_mean_m ??= vrmsValues.reduce((sum, value) => sum + value, 0) / vrmsValues.length;
enriched.receiver_vrms_rms_m ??= rootMeanSquare(vrmsValues);
enriched.receiver_vrms_latest_m ??= vrmsValues[vrmsValues.length - 1];
}
if (enriched.rms_m != null && enriched.receiver_hrms_rms_m != null) {
enriched.rms_minus_receiver_hrms_m ??= enriched.rms_m - enriched.receiver_hrms_rms_m;
enriched.rms_to_receiver_hrms_ratio ??= enriched.receiver_hrms_rms_m > 0 ? enriched.rms_m / enriched.receiver_hrms_rms_m : null;
}
return enriched;
}
function metricCell(label, value) {
const cell = document.createElement("div");
cell.className = "stat";
const labelNode = document.createElement("span");
labelNode.textContent = label;
const valueNode = document.createElement("strong");
valueNode.textContent = value;
cell.append(labelNode, valueNode);
return cell;
}
function renderMetrics() {
const points = measurementPoints();
const metrics = measurementMetrics(points);
const sourceCount = metrics.gnpos_count != null ? `${metrics.gnpos_count} GNPOS` : "--";
const allPoints = allMeasurementPoints();
const allValidCount = allPoints.filter((point) => point.latitude != null && point.longitude != null).length;
const stableFixedCount = rtkFixedStreakPoints(allPoints).filter((point) => point.latitude != null && point.longitude != null).length;
const fixedPercent = allValidCount
? `${stableFixedCount} / ${allValidCount} (${((stableFixedCount / allValidCount) * 100).toFixed(1)}%)`
: "--";
const weather = state.measurement.weather;
const weatherLabel =
weather?.record_type === "weather"
? `${weather.station?.id || "NWS"} ${weather.observation?.text_description || ""}`.trim()
: weather?.record_type === "weather_error"
? "NWS unavailable"
: "--";
const stats = $("precisionStats");
stats.replaceChildren(
metricCell("Samples", String(metrics.count ?? points.length)),
metricCell("Weather", weatherLabel),
metricCell("GNPOS Samples", sourceCount),
metricCell("RTK Fixed 5x", fixedPercent),
metricCell("Measured RMS", fmt(metrics.rms_m, 3, " m")),
metricCell("Receiver HRMS", fmt(metrics.receiver_hrms_rms_m, 3, " m")),
metricCell("RMS - HRMS", fmt(metrics.rms_minus_receiver_hrms_m, 3, " m")),
metricCell("RMS / HRMS", metrics.rms_to_receiver_hrms_ratio == null ? "--" : `${metrics.rms_to_receiver_hrms_ratio.toFixed(2)}x`),
metricCell("Within HRMS", fmt(metrics.within_receiver_hrms_percent, 1, "%")),
metricCell("Avg HRMS", fmt(metrics.receiver_hrms_mean_m, 3, " m")),
metricCell("Latest H/V RMS", `${fmt(metrics.receiver_hrms_latest_m, 3, " m")} / ${fmt(metrics.receiver_vrms_latest_m, 3, " m")}`),
metricCell("Avg VRMS", fmt(metrics.receiver_vrms_mean_m, 3, " m")),
metricCell("CEP50", fmt(metrics.cep50_m, 3, " m")),
metricCell("R95 / CEP95", fmt(metrics.r95_m ?? metrics.cep95_m, 3, " m")),
metricCell("2DRMS", fmt(metrics.two_drms_m, 3, " m")),
metricCell("Mean Error", fmt(metrics.mean_error_m, 3, " m")),
metricCell("Max Error", fmt(metrics.max_error_m, 3, " m")),
metricCell("Std E / N", `${fmt(metrics.std_east_m, 3, " m")} / ${fmt(metrics.std_north_m, 3, " m")}`),
metricCell("Span 2D", fmt(metrics.span_2d_m, 3, " m")),
metricCell("Mean Lat", fmt(metrics.mean_latitude, 9)),
metricCell("Mean Lon", fmt(metrics.mean_longitude, 9)),
metricCell("File", state.measurement.file || "--")
);
}
function renderLogStatus() {
const { logging, mode, file, points } = state.measurement;
const prefix = logging ? "Logging" : mode === "log" ? "Loaded" : "Idle";
const totalPoints = state.measurement.totalCount || allMeasurementPoints().length;
const filteredSuffix = state.measurement.fixedOnly ? `, using ${points.length} RTK Fixed 5x` : "";
$("logStatus").textContent = `${prefix}: ${file || "no file"} (${totalPoints} points${filteredSuffix})`;
$("startLogBtn").disabled = logging;
$("stopLogBtn").disabled = !logging;
$("sessionNotes").disabled = logging;
}
function drawPlot() {
const canvas = $("precisionPlot");
if (!canvas) return;
const wrap = canvas.parentElement;
const size = Math.max(260, Math.floor(wrap.clientWidth));
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(size * dpr);
canvas.height = Math.floor(size * dpr);
canvas.style.height = `${size}px`;
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = "#0a0d0e";
ctx.fillRect(0, 0, size, size);
const pad = 34;
const plotSize = size - pad * 2;
ctx.strokeStyle = "#222b2e";
ctx.lineWidth = 1;
ctx.strokeRect(pad, pad, plotSize, plotSize);
for (let i = 1; i < 4; i += 1) {
const pos = pad + (plotSize * i) / 4;
ctx.beginPath();
ctx.moveTo(pos, pad);
ctx.lineTo(pos, size - pad);
ctx.moveTo(pad, pos);
ctx.lineTo(size - pad, pos);
ctx.stroke();
}
const points = measurementPoints().filter((point) => point.latitude != null && point.longitude != null);
if (!points.length) {
ctx.fillStyle = "#9fb0aa";
ctx.font = "14px system-ui";
ctx.textAlign = "center";
ctx.fillText(state.measurement.fixedOnly ? "No RTK Fixed 5x points" : "No points", size / 2, size / 2);
return;
}
const originLat = points.reduce((sum, point) => sum + Number(point.latitude), 0) / points.length;
const originLon = points.reduce((sum, point) => sum + Number(point.longitude), 0) / points.length;
const offsets = points.map((point) => pointOffset(point, originLat, originLon));
const maxExtent = Math.max(0.05, ...offsets.map((offset) => Math.max(Math.abs(offset.east), Math.abs(offset.north)))) * 1.15;
const scale = plotSize / (maxExtent * 2);
ctx.strokeStyle = "#344044";
ctx.beginPath();
ctx.moveTo(size / 2, pad);
ctx.lineTo(size / 2, size - pad);
ctx.moveTo(pad, size / 2);
ctx.lineTo(size - pad, size / 2);
ctx.stroke();
ctx.fillStyle = "#9fb0aa";
ctx.font = "12px system-ui";
ctx.textAlign = "left";
ctx.fillText(`+/- ${fmt(maxExtent, 2, " m")}`, pad, size - 12);
offsets.forEach((offset, index) => {
const x = size / 2 + offset.east * scale;
const y = size / 2 - offset.north * scale;
ctx.beginPath();
ctx.arc(x, y, index === offsets.length - 1 ? 4 : 2.8, 0, Math.PI * 2);
ctx.fillStyle = index === offsets.length - 1 ? "#f4c45f" : "#58ddbb";
ctx.fill();
});
}
function trendMetricConfig() {
return TREND_METRICS.find((metric) => metric.key === $("trendMetric").value) || TREND_METRICS[0];
}
function timedPoints() {
return measurementPoints()
.map((point) => ({ ...point, tsMs: Date.parse(point.received_at || "") }))
.filter((point) => Number.isFinite(point.tsMs) && point.latitude != null && point.longitude != null)
.sort((a, b) => a.tsMs - b.tsMs);
}
function drawLine(ctx, series, xFor, yFor, color, width = 2) {
const drawable = series.filter((item) => item.value != null && Number.isFinite(item.value));
if (!drawable.length) return;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
drawable.forEach((item, index) => {
const x = xFor(item);
const y = yFor(item.value);
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
function drawMetricTrend() {
const canvas = $("metricTrendPlot");
if (!canvas) return;
const wrap = canvas.parentElement;
const width = Math.max(320, Math.floor(wrap.clientWidth));
const height = Math.max(240, Math.floor(wrap.clientHeight));
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#0a0d0e";
ctx.fillRect(0, 0, width, height);
const pad = { left: 54, right: 18, top: 24, bottom: 36 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
ctx.strokeStyle = "#222b2e";
ctx.lineWidth = 1;
ctx.strokeRect(pad.left, pad.top, plotW, plotH);
const points = timedPoints();
const metric = trendMetricConfig();
const windowMinutes = Math.max(0.1, Number($("trendWindow").value) || 5);
const windowMs = windowMinutes * 60 * 1000;
if (points.length < 2) {
ctx.fillStyle = "#9fb0aa";
ctx.font = "14px system-ui";
ctx.textAlign = "center";
ctx.fillText(
state.measurement.fixedOnly ? "Need at least two timestamped RTK Fixed 5x points" : "Need at least two timestamped points",
width / 2,
height / 2
);
return;
}
const startMs = points[0].tsMs;
const endMs = points[points.length - 1].tsMs;
const fullMetrics = calculatePointMetrics(points);
const fullValue = fullMetrics[metric.key];
const windowSeries = points.map((point, index) => {
const windowStart = point.tsMs - windowMs;
const subset = points.slice(0, index + 1).filter((candidate) => candidate.tsMs >= windowStart);
return { tsMs: point.tsMs, value: calculatePointMetrics(subset)[metric.key] };
});
const fullSeries = points.map((point) => ({ tsMs: point.tsMs, value: fullValue }));
const values = [...windowSeries.map((item) => item.value), fullValue].filter((value) => value != null && Number.isFinite(value));
if (!values.length) {
ctx.fillStyle = "#9fb0aa";
ctx.font = "14px system-ui";
ctx.textAlign = "center";
ctx.fillText("Metric is not available for these points", width / 2, height / 2);
return;
}
let yMin = Math.min(...values);
let yMax = Math.max(...values);
const yPad = Math.max((yMax - yMin) * 0.12, yMax === 0 ? 0.001 : Math.abs(yMax) * 0.08);
yMin -= yPad;
yMax += yPad;
if (yMin === yMax) {
yMin -= 1;
yMax += 1;
}
const xFor = (item) => pad.left + ((item.tsMs - startMs) / Math.max(1, endMs - startMs)) * plotW;
const yFor = (value) => pad.top + plotH - ((value - yMin) / (yMax - yMin)) * plotH;
ctx.strokeStyle = "#344044";
ctx.fillStyle = "#9fb0aa";
ctx.font = "12px system-ui";
ctx.textAlign = "right";
for (let i = 0; i <= 4; i += 1) {
const y = pad.top + (plotH * i) / 4;
const value = yMax - ((yMax - yMin) * i) / 4;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(width - pad.right, y);
ctx.stroke();
ctx.fillText(fmt(value, metric.digits, metric.suffix), pad.left - 8, y + 4);
}
drawLine(ctx, windowSeries, xFor, yFor, "#58ddbb", 2);
drawLine(ctx, fullSeries, xFor, yFor, "#f4c45f", 2);
ctx.fillStyle = "#eff4f2";
ctx.textAlign = "left";
ctx.font = "12px system-ui";
ctx.fillText(`${metric.label}: ${windowMinutes} min window`, pad.left, 16);
ctx.fillStyle = "#58ddbb";
ctx.fillText("Window", pad.left, height - 12);
ctx.fillStyle = "#f4c45f";
ctx.fillText(`Full period ${fmt(fullValue, metric.digits, metric.suffix)}`, pad.left + 72, height - 12);
ctx.fillStyle = "#9fb0aa";
ctx.textAlign = "right";
const elapsedMin = (endMs - startMs) / 60000;
ctx.fillText(`${elapsedMin.toFixed(1)} min`, width - pad.right, height - 12);
}
function renderMeasurement() {
renderLogStatus();
renderMetrics();
drawPlot();
drawMetricTrend();
}
async function loadCommands() {
const data = await api("/api/commands");
state.commands = data.commands;
$("commandSelect").replaceChildren(...state.commands.map((cmd) => option(`${cmd.name} - ${cmd.title}`, cmd.name)));
renderCommandFields();
}
function selectedCommand() {
return state.commands.find((cmd) => cmd.name === $("commandSelect").value);
}
function renderCommandFields() {
const cmd = selectedCommand();
const fields = $("commandFields");
fields.replaceChildren();
if (!cmd) return;
const supportsSet = Boolean((cmd.set_params || []).length);
$("setBtn").disabled = !supportsSet;
if (!supportsSet && state.action === "SET") {
state.action = "GET";
$("getBtn").classList.add("active");
$("setBtn").classList.remove("active");
}
$("commandHint").textContent = cmd.description;
if (state.action === "GET") {
$("commandInput").value = `${cmd.name}=GET`;
return;
}
for (const param of cmd.set_params || []) {
const wrap = document.createElement("div");
const label = document.createElement("label");
label.textContent = param.label;
wrap.appendChild(label);
let input;
if (param.type === "select") {
input = document.createElement("select");
for (const [value, text] of param.options) input.appendChild(option(text, value));
} else if (param.type === "bool") {
input = document.createElement("select");
input.appendChild(option("Keep", ""));
input.appendChild(option("Off", "0"));
input.appendChild(option("On", "1"));
} else {
input = document.createElement("input");
input.type = param.type || "text";
input.placeholder = param.optional ? "optional" : "";
}
input.dataset.param = param.name;
input.dataset.prefix = param.prefix || "";
input.addEventListener("input", updateCommandInput);
input.addEventListener("change", updateCommandInput);
wrap.appendChild(input);
fields.appendChild(wrap);
}
updateCommandInput();
}
function updateCommandInput() {
const cmd = selectedCommand();
if (!cmd) return;
if (state.action === "GET") {
$("commandInput").value = `${cmd.name}=GET`;
return;
}
const params = [];
for (const input of $("commandFields").querySelectorAll("[data-param]")) {
const value = input.value;
const prefix = input.dataset.prefix;
if (prefix && value !== "") params.push(prefix);
params.push(value);
}
while (params.length && params[params.length - 1] === "") params.pop();
$("commandInput").value = `${cmd.name}=SET${params.length ? "," + params.join(",") : ""}`;
}
async function scan() {
$("scanBtn").disabled = true;
log("[scan] Searching for BLE devices...");
try {
const data = await api("/api/scan", { method: "POST", body: JSON.stringify({ timeout: 5 }) });
const options = data.devices.map((dev) => option(`${dev.name} (${dev.address})${dev.rssi ? ` ${dev.rssi} dBm` : ""}`, dev.address));
$("deviceSelect").replaceChildren(...options);
log(`[scan] Found ${data.devices.length} device(s).`);
} catch (err) {
log(`[scan] ${err.message}`, "bad");
} finally {
$("scanBtn").disabled = false;
}
}
async function connect() {
const address = $("deviceSelect").value.trim();
if (!address) {
log("[connect] Pick a device first.", "bad");
return;
}
$("connectBtn").disabled = true;
try {
const data = await api("/api/connect", { method: "POST", body: JSON.stringify({ address }) });
applyStatus(data);
log(`[connect] Connected to ${address}`, "ok");
} catch (err) {
log(`[connect] ${err.message}`, "bad");
} finally {
$("connectBtn").disabled = false;
}
}
async function disconnect() {
try {
await api("/api/disconnect", { method: "POST", body: "{}" });
applyStatus({ connected: false, services: [] });
log("[connect] Disconnected.");
} catch (err) {
log(`[disconnect] ${err.message}`, "bad");
}
}
function applyStatus(data) {
state.services = data.services || state.services || [];
setStatus(Boolean(data.connected), data.connected ? `${data.address || ""}` : "Disconnected");
populateCharacteristics(data.tx_char, data.rx_char);
}
function populateCharacteristics(txChar, rxChar) {
const chars = [];
for (const service of state.services) {
for (const char of service.characteristics) {
const props = char.properties.join(",");
chars.push({ uuid: char.uuid, label: `${char.uuid} (${props})` });
}
}
const empty = [option("No characteristic", "")];
$("txChar").replaceChildren(...empty, ...chars.map((char) => option(char.label, char.uuid)));
$("rxChar").replaceChildren(option("No characteristic", ""), ...chars.map((char) => option(char.label, char.uuid)));
if (txChar) $("txChar").value = txChar;
if (rxChar) $("rxChar").value = rxChar;
}
async function applyCharacteristics() {
try {
const data = await api("/api/characteristics", {
method: "POST",
body: JSON.stringify({ tx_char: $("txChar").value || null, rx_char: $("rxChar").value || null }),
});
applyStatus(data);
log("[ble] Characteristics applied.", "ok");
} catch (err) {
log(`[ble] ${err.message}`, "bad");
}
}
async function sendCommand(command) {
const clean = command.trim();
if (!clean) return;
try {
await api("/api/send", {
method: "POST",
body: JSON.stringify({ command: clean, append_crlf: true, response: false }),
});
} catch (err) {
log(`[send] ${err.message}`, "bad");
}
}
function applyMeasurementStatus(data, mode = state.measurement.mode) {
state.measurement.logging = Boolean(data.active);
state.measurement.mode = mode;
state.measurement.file = data.file || null;
if (data.notes != null) {
state.measurement.notes = data.notes;
$("sessionNotes").value = data.notes;
}
state.measurement.weather = data.weather || state.measurement.weather || null;
if (Array.isArray(data.points)) {
setMeasurementPoints(data.points);
}
state.measurement.totalCount = data.total_count ?? state.measurement.totalCount ?? state.measurement.points.length;
state.measurement.metrics = data.metrics || { count: state.measurement.points.length };
renderMeasurement();
}
async function startLogging() {
try {
const data = await api("/api/measure/start", {
method: "POST",
body: JSON.stringify({ notes: $("sessionNotes").value }),
});
applyMeasurementStatus(data, "live");
log(`[measure] Started ${data.file}`, "ok");
await loadLogList();
} catch (err) {
log(`[measure] ${err.message}`, "bad");
}
}
async function stopLogging() {
try {
const data = await api("/api/measure/stop", { method: "POST", body: "{}" });
applyMeasurementStatus(data, "live");
log(`[measure] Stopped ${data.file || "log"} with ${data.count || 0} point(s).`, "ok");
await loadLogList();
} catch (err) {
log(`[measure] ${err.message}`, "bad");
}
}
async function loadLogList() {
try {
const data = await api("/api/measure/logs");
const options = data.logs.map((item) => option(`${item.filename} (${Math.round(item.size_bytes / 1024)} KB)`, item.filename));
$("logSelect").replaceChildren(...options);
} catch (err) {
log(`[measure] ${err.message}`, "bad");
}
}
async function openSelectedLog() {
const filename = $("logSelect").value;
if (!filename) {
log("[measure] No saved log selected.", "bad");
return;
}
try {
state.measurement.fixedOnly = $("fixedOnlyToggle").checked;
const data = await api(`/api/measure/logs/${encodeURIComponent(filename)}`);
state.measurement.mode = "log";
state.measurement.file = data.file;
state.measurement.notes = data.notes || "";
state.measurement.weather = data.weather || null;
$("sessionNotes").value = state.measurement.notes;
state.measurement.allPoints = data.points || [];
state.measurement.points = filteredMeasurementPoints(state.measurement.allPoints);
state.measurement.totalCount = data.total_count ?? state.measurement.allPoints.length;
state.measurement.metrics = state.measurement.fixedOnly
? calculatePointMetrics(state.measurement.points)
: data.metrics || { count: state.measurement.points.length };
renderMeasurement();
const total = state.measurement.fixedOnly ? ` of ${state.measurement.totalCount}` : "";
log(`[measure] Opened ${data.file} with ${state.measurement.points.length}${total} point(s).`, "ok");
} catch (err) {
log(`[measure] ${err.message}`, "bad");
}
}
function updateDashboard(data) {
$("fixStatus").textContent = data.status_text || "--";
$("accuracy").textContent = data.hrms_m != null && data.vrms_m != null ? `H ${data.hrms_m}m / V ${data.vrms_m}m` : "--";
$("satellites").textContent =
data.satellites_used != null && data.satellites_visible != null ? `${data.satellites_used}/${data.satellites_visible}` : "--";
$("battery").textContent =
data.battery_percent != null && data.battery_voltage != null ? `${data.battery_percent}% (${data.battery_voltage}V)` : "--";
$("position").textContent =
data.latitude != null && data.longitude != null ? `${data.latitude.toFixed(9)}, ${data.longitude.toFixed(9)}` : "--";
$("ntrip").textContent = data.ntrip_connected ? `Connected ${data.correction_age_s ?? ""}s` : "Disconnected";
}
function openSocket() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => log("[ws] Connected.", "ok");
ws.onclose = () => {
log("[ws] Closed. Reconnecting...");
setTimeout(openSocket, 1500);
};
ws.onmessage = (message) => {
let event;
try {
event = JSON.parse(message.data);
} catch {
return;
}
if (event.type === "tx") log(`> ${event.text}`, "tx");
if (event.type === "rx") log(event.text, "rx");
if (event.type === "line" && ["gnpos", "gga", "rmc"].includes(event.kind) && event.data) updateDashboard(event.data);
if (event.type === "line" && event.kind === "gndev" && event.data) {
log(`[device] SN ${event.data.serial_number} FW ${event.data.firmware_version}`, "ok");
}
if (event.type === "line" && event.checksum_ok === false) log(`[checksum] Failed: ${event.line}`, "bad");
if (event.type === "measurement_status") {
applyMeasurementStatus(event, state.measurement.mode === "log" && !event.active ? "log" : "live");
}
if (event.type === "measurement_point" && state.measurement.mode === "live") {
state.measurement.logging = true;
state.measurement.file = event.file || state.measurement.file;
if (!state.measurement.allPoints.some((point) => point.index === event.point.index)) {
state.measurement.allPoints.push(event.point);
}
state.measurement.totalCount = state.measurement.allPoints.length;
refreshMeasurementPointFilter();
state.measurement.metrics = event.metrics || state.measurement.metrics;
renderMeasurement();
}
if (event.type === "measurement_metrics" && state.measurement.mode === "live") {
state.measurement.metrics = event.metrics || state.measurement.metrics;
renderMeasurement();
}
if (event.type === "measurement_weather" && state.measurement.mode === "live") {
state.measurement.weather = event.weather || null;
const obs = event.weather?.observation;
const station = event.weather?.station?.id || "NWS";
if (event.weather?.record_type === "weather") {
log(`[measure] Weather ${station}: ${obs?.text_description || "conditions captured"}`, "ok");
} else {
log(`[measure] Weather capture failed: ${event.weather?.error || "unknown error"}`, "bad");
}
renderMeasurement();
}
if (event.type === "connection") {
setStatus(Boolean(event.connected), event.message || (event.connected ? "Connected" : "Disconnected"));
}
if (event.type === "status") log(`[status] ${event.message}`);
};
}
function bindEvents() {
state.measurement.fixedOnly = $("fixedOnlyToggle").checked;
$("scanBtn").addEventListener("click", scan);
$("connectBtn").addEventListener("click", connect);
$("disconnectBtn").addEventListener("click", disconnect);
$("applyCharsBtn").addEventListener("click", applyCharacteristics);
$("sendBtn").addEventListener("click", () => sendCommand($("commandInput").value));
$("startLogBtn").addEventListener("click", startLogging);
$("stopLogBtn").addEventListener("click", stopLogging);
$("openLogBtn").addEventListener("click", openSelectedLog);
$("refreshLogsBtn").addEventListener("click", loadLogList);
$("fixedOnlyToggle").addEventListener("change", (event) => {
state.measurement.fixedOnly = event.target.checked;
if (state.measurement.mode === "log" && state.measurement.file) {
openSelectedLog();
return;
}
refreshMeasurementPointFilter();
renderMeasurement();
});
$("trendMetric").addEventListener("change", drawMetricTrend);
$("trendWindow").addEventListener("input", drawMetricTrend);
$("commandInput").addEventListener("keydown", (event) => {
if (event.key === "Enter") sendCommand($("commandInput").value);
});
$("commandSelect").addEventListener("change", renderCommandFields);
$("getBtn").addEventListener("click", () => {
state.action = "GET";
$("getBtn").classList.add("active");
$("setBtn").classList.remove("active");
renderCommandFields();
});
$("setBtn").addEventListener("click", () => {
state.action = "SET";
$("setBtn").classList.add("active");
$("getBtn").classList.remove("active");
renderCommandFields();
});
$("clearBtn").addEventListener("click", () => $("terminal").replaceChildren());
document.querySelectorAll(".quick").forEach((button) => button.addEventListener("click", () => sendCommand(button.dataset.command)));
window.addEventListener("resize", () => {
drawPlot();
drawMetricTrend();
});
}
function populateTrendMetrics() {
$("trendMetric").replaceChildren(...TREND_METRICS.map((metric) => option(metric.label, metric.key)));
$("trendMetric").value = "rms_m";
}
async function boot() {
populateTrendMetrics();
bindEvents();
renderMeasurement();
openSocket();
await loadCommands();
await loadLogList();
try {
applyStatus(await api("/api/status"));
} catch (err) {
log(`[status] ${err.message}`, "bad");
}
try {
applyMeasurementStatus(await api("/api/measure/status"), "live");
} catch (err) {
log(`[measure] ${err.message}`, "bad");
}
}
boot();