Add static assets
This commit is contained in:
925
static/app.js
Normal file
925
static/app.js
Normal 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();
|
||||||
156
static/index.html
Normal file
156
static/index.html
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>H11 RTK BLE Console</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>H11 RTK BLE Console</h1>
|
||||||
|
<p id="statusText">Disconnected</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-pill" id="statusPill">Offline</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="layout">
|
||||||
|
<aside class="panel side">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Device</h2>
|
||||||
|
<button id="scanBtn">Scan</button>
|
||||||
|
</div>
|
||||||
|
<div class="scan-row">
|
||||||
|
<select id="deviceSelect"></select>
|
||||||
|
<button id="connectBtn">Connect</button>
|
||||||
|
</div>
|
||||||
|
<button class="secondary full" id="disconnectBtn">Disconnect</button>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3>Characteristics</h3>
|
||||||
|
<label>TX Write</label>
|
||||||
|
<select id="txChar"></select>
|
||||||
|
<label>RX Notify</label>
|
||||||
|
<select id="rxChar"></select>
|
||||||
|
<button class="secondary full" id="applyCharsBtn">Apply Characteristics</button>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h3>Quick Actions</h3>
|
||||||
|
<button class="secondary full quick" data-command="AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0">Enable GNPOS/GNDEV</button>
|
||||||
|
<button class="secondary full quick" data-command="AT+BT_OUT=GET">Get BT Output</button>
|
||||||
|
<button class="secondary full quick" data-command="AT+GNSS_MODE=GET">Get GNSS Mode</button>
|
||||||
|
<button class="secondary full quick" data-command="AT+DEV_INIT_STA=GET">Get Init Status</button>
|
||||||
|
<button class="secondary full quick" data-command="AT+NEMATIME=GET">Get NMEA Frequency</button>
|
||||||
|
<button class="secondary full quick" data-command="AT+RTCMBASEPOS=GET">Get RTCM Base</button>
|
||||||
|
<button class="secondary full quick" data-command="AT+UPLOADDATA_PARM=GET">Get Upload Server</button>
|
||||||
|
<button class="secondary full quick" data-command="AT+UPLOADDATA_TYPE=GET">Get Upload Protocol</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="main-stack">
|
||||||
|
<section class="panel dashboard">
|
||||||
|
<div class="metric">
|
||||||
|
<span>Fix</span>
|
||||||
|
<strong id="fixStatus">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>Accuracy</span>
|
||||||
|
<strong id="accuracy">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>Satellites</span>
|
||||||
|
<strong id="satellites">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>Battery</span>
|
||||||
|
<strong id="battery">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric wide">
|
||||||
|
<span>Position</span>
|
||||||
|
<strong id="position">--</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>NTRIP</span>
|
||||||
|
<strong id="ntrip">--</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel precision-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Precision</h2>
|
||||||
|
<div class="precision-actions">
|
||||||
|
<button id="startLogBtn">Start Log</button>
|
||||||
|
<button class="secondary" id="stopLogBtn">Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-row">
|
||||||
|
<select id="logSelect"></select>
|
||||||
|
<button class="secondary" id="openLogBtn">Open Log</button>
|
||||||
|
<button class="secondary" id="refreshLogsBtn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<label class="inline-check">
|
||||||
|
<input id="fixedOnlyToggle" type="checkbox" />
|
||||||
|
<span>Use RTK Fixed points only after 5 fixed readings</span>
|
||||||
|
</label>
|
||||||
|
<label for="sessionNotes">Session Notes</label>
|
||||||
|
<textarea id="sessionNotes" rows="3" spellcheck="true"></textarea>
|
||||||
|
<div class="precision-layout">
|
||||||
|
<div class="plot-wrap">
|
||||||
|
<canvas id="precisionPlot"></canvas>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="log-status" id="logStatus">No active log</div>
|
||||||
|
<div class="stats-grid" id="precisionStats"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="trend-controls">
|
||||||
|
<div>
|
||||||
|
<label for="trendMetric">Trend Metric</label>
|
||||||
|
<select id="trendMetric"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="trendWindow">Window Minutes</label>
|
||||||
|
<input id="trendWindow" type="number" min="0.1" step="0.1" value="5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="trend-wrap">
|
||||||
|
<canvas id="metricTrendPlot"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Commands</h2>
|
||||||
|
<div class="segmented">
|
||||||
|
<button id="getBtn" class="active">GET</button>
|
||||||
|
<button id="setBtn">SET</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="command-grid">
|
||||||
|
<div>
|
||||||
|
<label>Command</label>
|
||||||
|
<select id="commandSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div id="commandFields" class="fields"></div>
|
||||||
|
</div>
|
||||||
|
<div class="send-row">
|
||||||
|
<input id="commandInput" spellcheck="false" placeholder="AT+GNSS_MODE=GET" />
|
||||||
|
<button id="sendBtn">Send</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="commandHint"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel terminal-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Terminal</h2>
|
||||||
|
<button class="secondary" id="clearBtn">Clear</button>
|
||||||
|
</div>
|
||||||
|
<pre id="terminal"></pre>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/app.js?v=20260611-fixed-streak-client"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
454
static/styles.css
Normal file
454
static/styles.css
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #111415;
|
||||||
|
--panel: #1c2224;
|
||||||
|
--panel-soft: #242b2e;
|
||||||
|
--text: #eff4f2;
|
||||||
|
--muted: #9fb0aa;
|
||||||
|
--line: #344044;
|
||||||
|
--accent: #33c7a2;
|
||||||
|
--accent-strong: #58ddbb;
|
||||||
|
--danger: #ff716b;
|
||||||
|
--warn: #f4c45f;
|
||||||
|
--button: #2d6f88;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--button);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #121718;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 82px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1440px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p,
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
min-width: 110px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.online {
|
||||||
|
color: #071512;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 340px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-row,
|
||||||
|
.send-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-height: 76px;
|
||||||
|
background: #151a1c;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-row {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-actions,
|
||||||
|
.log-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-check input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 420px) minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot-wrap {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border: 1px solid #222b2e;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0a0d0e;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#precisionPlot {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 180px;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
border: 1px solid #222b2e;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0a0d0e;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#metricTrendPlot {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-status {
|
||||||
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #151a1c;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0 10px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
min-height: 68px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #151a1c;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: flex;
|
||||||
|
background: #121718;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
min-height: 32px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #071512;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#terminal {
|
||||||
|
height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0a0d0e;
|
||||||
|
border: 1px solid #222b2e;
|
||||||
|
color: #c7f7e7;
|
||||||
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx {
|
||||||
|
color: #8dc8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx {
|
||||||
|
color: #d6f7e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.layout,
|
||||||
|
.command-grid,
|
||||||
|
.precision-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.shell {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard,
|
||||||
|
.fields,
|
||||||
|
.scan-row,
|
||||||
|
.send-row,
|
||||||
|
.log-row,
|
||||||
|
.trend-controls,
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precision-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric.wide {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user