From cd7cb862fd235ccaeeb9e7d44410a34cc8fca7d2 Mon Sep 17 00:00:00 2001
From: firestar5683 <168790843+firestar5683@users.noreply.github.com>
Date: Thu, 5 Mar 2026 16:26:12 -0600
Subject: [PATCH] plots
---
.../the_pond/assets/components/router.js | 2 +
.../the_pond/assets/components/sidebar.js | 1 +
.../assets/components/tools/plots.css | 212 +++++++++
.../the_pond/assets/components/tools/plots.js | 447 ++++++++++++++++++
.../system/the_pond/templates/index.html | 1 +
frogpilot/system/the_pond/the_pond.py | 199 ++++++++
6 files changed, 862 insertions(+)
create mode 100644 frogpilot/system/the_pond/assets/components/tools/plots.css
create mode 100644 frogpilot/system/the_pond/assets/components/tools/plots.js
diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js
index a4b99f5f1..7c2c60c75 100644
--- a/frogpilot/system/the_pond/assets/components/router.js
+++ b/frogpilot/system/the_pond/assets/components/router.js
@@ -14,6 +14,7 @@ import { ScreenRecordings } from "/assets/components/recordings/screen_recording
import { Sidebar } from "/assets/components/sidebar.js"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260303t"
+import { LivePlots } from "/assets/components/tools/plots.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
@@ -43,6 +44,7 @@ function Root() {
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
createRoute("model_manager", "/manage_models", ModelManager),
+ createRoute("plots", "/plots", LivePlots),
createRoute("thememaker", "/theme_maker", ThemeMaker),
createRoute("tmux", "/manage_tmux", TmuxLog),
createRoute("toggles", "/manage_toggles", ToggleControl),
diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js
index d37bb99d4..3012e28ac 100644
--- a/frogpilot/system/the_pond/assets/components/sidebar.js
+++ b/frogpilot/system/the_pond/assets/components/sidebar.js
@@ -20,6 +20,7 @@ const MenuItems = {
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
+ { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
{ name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" },
diff --git a/frogpilot/system/the_pond/assets/components/tools/plots.css b/frogpilot/system/the_pond/assets/components/tools/plots.css
new file mode 100644
index 000000000..b311d5ac7
--- /dev/null
+++ b/frogpilot/system/the_pond/assets/components/tools/plots.css
@@ -0,0 +1,212 @@
+.plotsPage {
+ max-width: var(--width-xxxxl);
+}
+
+.plotCard {
+ background-color: var(--secondary-bg);
+ border-radius: var(--border-radius-md);
+ color: var(--text-color);
+ max-width: 95%;
+ padding: var(--border-radius-xl);
+}
+
+.plotStatusCard {
+ margin-bottom: var(--margin-base);
+}
+
+.plotDescription {
+ margin: 0;
+}
+
+.plotStatusGrid {
+ display: grid;
+ gap: 0.5em 1em;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-top: var(--margin-base);
+}
+
+.plotStatusGrid p,
+.plotRangeRow span,
+.plotLegendItem {
+ font-size: var(--font-size-sm);
+ margin: 0;
+}
+
+.plotActions {
+ display: flex;
+ gap: var(--gap-sm);
+ margin-top: var(--margin-base);
+}
+
+.plotButton {
+ background: var(--main-fg);
+ border: none;
+ border-radius: var(--border-radius-sm);
+ color: var(--text-color);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-demi-bold);
+ padding: var(--padding-sm) var(--padding-base);
+}
+
+.plotButton:hover {
+ transform: var(--hover-scale-sm);
+ transition: transform var(--transition-fast), background-color var(--transition-fast);
+}
+
+.plotCharts {
+ display: grid;
+ gap: var(--gap-sm);
+ grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
+ max-width: 95%;
+}
+
+.plotAdvancedRow {
+ margin: var(--margin-base) 0;
+}
+
+.plotChartCard {
+ max-width: 100%;
+}
+
+.plotCardHeader {
+ align-items: baseline;
+ display: flex;
+ gap: var(--gap-sm);
+ justify-content: space-between;
+}
+
+.plotSource {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+.plotLegend {
+ display: flex;
+ gap: var(--gap-sm);
+ margin-bottom: var(--margin-sm);
+}
+
+.plotLegendItem {
+ align-items: center;
+ display: inline-flex;
+ gap: var(--gap-xs);
+}
+
+.plotLegendLine {
+ border-radius: var(--border-radius-sm);
+ display: inline-block;
+ height: 3px;
+ width: 22px;
+}
+
+.plotLegendLine.desired,
+.plotLine.desired {
+ background-color: var(--main-fg);
+ stroke: var(--main-fg);
+}
+
+.plotLegendLine.actual,
+.plotLine.actual {
+ background-color: var(--success-fg);
+ stroke: var(--success-fg);
+}
+
+.plotLegendLine.p,
+.plotLine.p {
+ background-color: #63b3ff;
+ stroke: #63b3ff;
+}
+
+.plotLegendLine.i,
+.plotLine.i {
+ background-color: #63d79d;
+ stroke: #63d79d;
+}
+
+.plotLegendLine.d,
+.plotLine.d {
+ background-color: #f0b35e;
+ stroke: #f0b35e;
+}
+
+.plotLegendLine.f,
+.plotLine.f {
+ background-color: #d08bff;
+ stroke: #d08bff;
+}
+
+.plotSvgWrap {
+ background: #0b0b18;
+ border: 1px solid var(--track-color);
+ border-radius: var(--border-radius-sm);
+ min-height: 260px;
+ overflow: hidden;
+}
+
+.plotSvg {
+ display: block;
+ height: 260px;
+ width: 100%;
+}
+
+.plotLine {
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 3;
+}
+
+.plotGridLine {
+ stroke: rgba(255, 255, 255, 0.08);
+ stroke-width: 1;
+}
+
+.plotZeroLine {
+ stroke: rgba(255, 255, 255, 0.35);
+ stroke-dasharray: 6 6;
+ stroke-width: 1;
+}
+
+.plotRangeRow {
+ display: flex;
+ justify-content: space-between;
+ margin-top: var(--margin-xs);
+}
+
+.plotEmpty {
+ align-items: center;
+ color: var(--text-muted);
+ display: flex;
+ font-size: var(--font-size-sm);
+ height: 260px;
+ justify-content: center;
+}
+
+.plotError {
+ color: var(--danger-fg);
+ margin-top: var(--margin-sm);
+}
+
+@media only screen and (max-width: 768px) and (orientation: portrait) {
+ .plotCard,
+ .plotCharts {
+ max-width: 100%;
+ }
+
+ .plotCharts {
+ grid-template-columns: 1fr;
+ }
+
+ .plotStatusGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .plotActions {
+ flex-direction: column;
+ }
+
+ .plotLegend {
+ flex-direction: column;
+ gap: var(--gap-xs);
+ }
+}
diff --git a/frogpilot/system/the_pond/assets/components/tools/plots.js b/frogpilot/system/the_pond/assets/components/tools/plots.js
new file mode 100644
index 000000000..caa5ccb46
--- /dev/null
+++ b/frogpilot/system/the_pond/assets/components/tools/plots.js
@@ -0,0 +1,447 @@
+import { html, reactive } from "https://esm.sh/@arrow-js/core"
+
+const state = reactive({
+ loading: true,
+ error: "",
+ paused: false,
+ showAdvancedTerms: false,
+ live: null,
+ samples: [],
+})
+
+let initialized = false
+let pollHandle = null
+let lastTimestamp = 0
+
+const MAX_POINTS = 240
+const POLL_INTERVAL_MS = 500
+const SVG_WIDTH = 1000
+const SVG_HEIGHT = 260
+const ADVANCED_TERMS_KEY = "plotsShowAdvancedTerms"
+
+function isPlotsRouteActive() {
+ return window.location.pathname === "/plots"
+}
+
+function toNumber(value, fallback = 0) {
+ const n = Number(value)
+ return Number.isFinite(n) ? n : fallback
+}
+
+function formatValue(value, digits = 2) {
+ return toNumber(value).toFixed(digits)
+}
+
+function formatAge(seconds) {
+ const value = Math.max(0, toNumber(seconds))
+ if (value < 1) return `${Math.round(value * 1000)} ms`
+ return `${value.toFixed(1)} s`
+}
+
+function formatSourceLabel(kind, source) {
+ const normalizedKind = String(kind || "").toLowerCase()
+ const normalizedSource = String(source || "").toLowerCase()
+
+ if (normalizedKind === "lateral") {
+ if (normalizedSource === "torquestate") return "Steering controller output"
+ if (normalizedSource === "curvature") return "Path model estimate"
+ }
+
+ if (normalizedKind === "longitudinal") {
+ if (normalizedSource.includes("livelocationkalman")) return "Control output + calibrated acceleration"
+ if (normalizedSource === "controlsstate") return "Planner target output"
+ }
+
+ if (normalizedKind === "lateralterms") {
+ if (normalizedSource === "torquestate") return "Steering torque controller"
+ if (normalizedSource === "pidstate") return "Steering angle PID controller"
+ }
+
+ if (normalizedKind === "longitudinalterms") {
+ if (normalizedSource === "controlsstate") return "Longitudinal controller terms"
+ }
+
+ return "Live control signal"
+}
+
+function stopPolling() {
+ if (!pollHandle) return
+ clearTimeout(pollHandle)
+ pollHandle = null
+}
+
+function pushSample(payload) {
+ const timestamp = toNumber(payload.timestamp, 0)
+ if (!timestamp || timestamp <= 0 || timestamp === lastTimestamp) return
+
+ lastTimestamp = timestamp
+ state.samples.push({
+ timestamp,
+ desiredLateralAccel: toNumber(payload.desiredLateralAccel),
+ actualLateralAccel: toNumber(payload.actualLateralAccel),
+ desiredLongitudinalAccel: toNumber(payload.desiredLongitudinalAccel),
+ actualLongitudinalAccel: toNumber(payload.actualLongitudinalAccel),
+ lateralP: toNumber(payload.lateralP),
+ lateralI: toNumber(payload.lateralI),
+ lateralD: toNumber(payload.lateralD),
+ lateralF: toNumber(payload.lateralF),
+ longitudinalUpAccelCmd: toNumber(payload.longitudinalUpAccelCmd),
+ longitudinalUiAccelCmd: toNumber(payload.longitudinalUiAccelCmd),
+ longitudinalUfAccelCmd: toNumber(payload.longitudinalUfAccelCmd),
+ })
+
+ if (state.samples.length > MAX_POINTS) {
+ state.samples.splice(0, state.samples.length - MAX_POINTS)
+ }
+}
+
+async function fetchLiveData() {
+ const response = await fetch("/api/plots/live")
+ const payload = await response.json()
+
+ if (!response.ok) {
+ throw new Error(payload.error || response.statusText || "Failed to load live plot data")
+ }
+
+ state.live = payload
+ state.error = ""
+ state.loading = false
+
+ if (!payload.stale) {
+ pushSample(payload)
+ }
+}
+
+function ensurePolling() {
+ if (pollHandle) return
+
+ const poll = async () => {
+ if (!isPlotsRouteActive()) {
+ pollHandle = null
+ return
+ }
+
+ if (!state.paused) {
+ try {
+ await fetchLiveData()
+ } catch (error) {
+ state.error = error?.message || String(error)
+ state.loading = false
+ }
+ }
+
+ pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
+ }
+
+ pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
+}
+
+function clearHistory() {
+ state.samples = []
+ lastTimestamp = 0
+}
+
+function togglePaused() {
+ state.paused = !state.paused
+ if (state.paused) {
+ stopPolling()
+ return
+ }
+
+ if (!isPlotsRouteActive()) return
+
+ fetchLiveData().catch((error) => {
+ state.error = error?.message || String(error)
+ })
+ ensurePolling()
+}
+
+function toggleAdvancedTerms() {
+ state.showAdvancedTerms = !state.showAdvancedTerms
+ try {
+ localStorage.setItem(ADVANCED_TERMS_KEY, state.showAdvancedTerms ? "1" : "0")
+ } catch (error) {
+ console.warn("Failed to persist plots advanced terms preference", error)
+ }
+}
+
+function yForValue(value, min, max) {
+ const safeMin = toNumber(min, -1)
+ const safeMax = toNumber(max, 1)
+ const clamped = Math.max(safeMin, Math.min(safeMax, toNumber(value)))
+ const span = Math.max(1e-6, safeMax - safeMin)
+ return ((safeMax - clamped) / span) * SVG_HEIGHT
+}
+
+function computeRange(samples, desiredKey, actualKey) {
+ let maxAbs = 0
+ for (const sample of samples) {
+ maxAbs = Math.max(
+ maxAbs,
+ Math.abs(toNumber(sample?.[desiredKey])),
+ Math.abs(toNumber(sample?.[actualKey])),
+ )
+ }
+
+ const halfSpan = Math.max(1.5, Math.ceil((maxAbs * 1.25) * 10) / 10)
+ return { min: -halfSpan, max: halfSpan }
+}
+
+function buildPolyline(samples, key, min, max) {
+ if (!samples.length) return ""
+
+ const lastIndex = Math.max(1, samples.length - 1)
+ return samples.map((sample, index) => {
+ const x = (index / lastIndex) * SVG_WIDTH
+ const y = yForValue(sample?.[key], min, max)
+ return `${x.toFixed(2)},${y.toFixed(2)}`
+ }).join(" ")
+}
+
+function computeRangeForKeys(samples, keys) {
+ let maxAbs = 0
+ for (const sample of samples) {
+ for (const key of keys) {
+ maxAbs = Math.max(maxAbs, Math.abs(toNumber(sample?.[key])))
+ }
+ }
+
+ const halfSpan = Math.max(0.15, Math.ceil((maxAbs * 1.35) * 1000) / 1000)
+ return { min: -halfSpan, max: halfSpan }
+}
+
+function latestSampleValue(key) {
+ const latest = state.samples[state.samples.length - 1]
+ if (latest) return latest[key]
+ return toNumber(state.live?.[key])
+}
+
+function PlotCard(title, desiredKey, actualKey, sourceKind, sourceLabel, desiredLabel = "Target", actualLabel = "Measured") {
+ const hasData = state.samples.length > 1
+ const range = computeRange(state.samples, desiredKey, actualKey)
+ const desiredPolyline = buildPolyline(state.samples, desiredKey, range.min, range.max)
+ const actualPolyline = buildPolyline(state.samples, actualKey, range.min, range.max)
+ const zeroY = yForValue(0, range.min, range.max)
+ const topQuarterY = yForValue(range.max * 0.5, range.min, range.max)
+ const bottomQuarterY = yForValue(range.min * 0.5, range.min, range.max)
+
+ return html`
+ ${title}
+ Source: ${formatSourceLabel(sourceKind, sourceLabel)}
+ Lateral Controller Terms
+ Source: ${formatSourceLabel("lateralTerms", sourceLabel)}
+ Longitudinal Accel Cmd Terms
+ Source: ${formatSourceLabel("longitudinalTerms", sourceLabel)}
+
+ Live comparison view for tuning diagnostics. If desired tracks actual closely, tuning is likely good. Large divergence often points to model mismatch or limits. +
+ +Onroad: ${state.live?.isOnroad ? "Yes" : "No"}
+Sample Age: ${formatAge(state.live?.sampleAgeSeconds)}
+Vehicle Speed: ${formatValue(state.live?.speed)} m/s
+Samples: ${state.samples.length}
+Error: ${state.error}
` : ""} + ${state.live?.lastError ? html`Source Error: ${state.live.lastError}
` : ""} +Loading live data...
` : ""} +