diff --git a/frogpilot/system/the_pond/assets/components/router.js b/frogpilot/system/the_pond/assets/components/router.js index fe8b96d3f..21827d144 100644 --- a/frogpilot/system/the_pond/assets/components/router.js +++ b/frogpilot/system/the_pond/assets/components/router.js @@ -6,6 +6,7 @@ import { ErrorLogs } from "/assets/components/tools/error_logs.js" import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js" import { GalaxyPairing } from "/assets/components/tools/galaxy.js" import { Home } from "/assets/components/home/home.js" +import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js" import { NavDestination } from "/assets/components/navigation/navigation_destination.js" import { NavKeys } from "/assets/components/navigation/navigation_keys.js" import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js" @@ -45,6 +46,7 @@ function Root() { createRoute("settings", "/settings/:section/:subsection?", SettingsView), createRoute("speed_limits", "/download_speed_limits", SpeedLimits), createRoute("model_manager", "/manage_models", ModelManager), + createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers), createRoute("plots", "/plots", LivePlots), createRoute("thememaker", "/theme_maker", ThemeMaker), createRoute("testing_ground", "/testing_ground", TestingGround), diff --git a/frogpilot/system/the_pond/assets/components/sidebar.js b/frogpilot/system/the_pond/assets/components/sidebar.js index 1a497ba0c..f083ac548 100644 --- a/frogpilot/system/the_pond/assets/components/sidebar.js +++ b/frogpilot/system/the_pond/assets/components/sidebar.js @@ -19,6 +19,7 @@ const MenuItems = { { name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" }, { name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" }, { name: "Galaxy", link: "/galaxy", icon: "bi-globe2" }, + { name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" }, { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" }, { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" }, { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" }, diff --git a/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.css b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.css new file mode 100644 index 000000000..e3f45ed24 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.css @@ -0,0 +1,117 @@ +.longManeuverPage { + max-width: var(--width-xxl); +} + +.longManeuverCard { + background-color: var(--secondary-bg); + border-radius: var(--border-radius-md); + color: var(--text-color); + max-width: 90%; + padding: var(--border-radius-xl); +} + +.longManeuverIntro { + margin-top: 0; +} + +.longManeuverActions { + display: flex; + gap: var(--gap-sm); + margin-top: var(--margin-base); +} + +.longManeuverButton { + 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); +} + +.longManeuverButton:hover { + transform: var(--hover-scale-sm); + transition: transform var(--transition-fast), background-color var(--transition-fast); +} + +.longManeuverButton:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; +} + +.longManeuverButton.danger { + background: var(--danger-bg); +} + +.longManeuverButton.danger:hover { + background: var(--danger-hover-bg); +} + +.longManeuverError { + color: var(--danger-fg); + margin-top: var(--margin-base); +} + +.longManeuverMuted { + color: var(--text-muted); +} + +.longManeuverStatusGrid { + display: grid; + gap: 0.5em 1em; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: var(--margin-base); +} + +.longManeuverStatusGrid p { + font-size: var(--font-size-sm); + margin: 0; +} + +.longManeuverCurrent { + margin-top: var(--margin-base); +} + +.longManeuverCurrent p { + font-size: var(--font-size-sm); + margin: 0 0 var(--margin-xs); +} + +.longManeuverInstructions, +.longManeuverHistory { + margin-top: var(--margin-base); +} + +.longManeuverInstructions h3, +.longManeuverHistory h3 { + margin-bottom: var(--margin-xs); +} + +.longManeuverInstructions ol, +.longManeuverHistory ol { + margin: var(--margin-xs) 0 0; + padding-left: 1.4rem; +} + +.longManeuverInstructions li, +.longManeuverHistory li { + font-size: var(--font-size-sm); + margin-bottom: 0.25rem; +} + +@media only screen and (max-width: 768px) and (orientation: portrait) { + .longManeuverCard { + max-width: 100%; + } + + .longManeuverActions { + flex-direction: column; + } + + .longManeuverStatusGrid { + grid-template-columns: 1fr; + } +} + diff --git a/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js new file mode 100644 index 000000000..77d895407 --- /dev/null +++ b/frogpilot/system/the_pond/assets/components/tools/longitudinal_maneuvers.js @@ -0,0 +1,170 @@ +import { html, reactive } from "https://esm.sh/@arrow-js/core" + +const state = reactive({ + loading: true, + busy: false, + error: "", + data: null, +}) + +let initialized = false +let pollHandle = null +const POLL_INTERVAL_MS = 1000 + +function safeNumber(value, fallback = 0) { + const n = Number(value) + return Number.isFinite(n) ? n : fallback +} + +function formatAgeSeconds(value) { + const sec = safeNumber(value, -1) + if (sec < 0) return "unknown" + if (sec < 1) return "just now" + if (sec < 60) return `${Math.round(sec)}s ago` + const min = sec / 60 + if (min < 60) return `${Math.round(min)}m ago` + const hr = min / 60 + return `${Math.round(hr)}h ago` +} + +async function fetchStatus() { + try { + const response = await fetch("/api/longitudinal_maneuvers/status") + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || response.statusText || "Failed to load maneuver status") + } + + state.data = payload + state.error = "" + } catch (error) { + state.error = error?.message || "Failed to load maneuver status" + } finally { + state.loading = false + } +} + +function stopPolling() { + if (!pollHandle) return + clearTimeout(pollHandle) + pollHandle = null +} + +function ensurePolling() { + if (pollHandle) return + + const poll = async () => { + await fetchStatus() + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) + } + + pollHandle = setTimeout(poll, POLL_INTERVAL_MS) +} + +async function runAction(action) { + if (state.busy) return + state.busy = true + + try { + const response = await fetch(`/api/longitudinal_maneuvers/${action}`, { method: "POST" }) + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error || response.statusText || `Failed to ${action} maneuvers`) + } + + state.data = payload + state.error = "" + showSnackbar(payload.message || "Action complete.") + } catch (error) { + const message = error?.message || `Failed to ${action} maneuvers` + state.error = message + showSnackbar(message, "error") + } finally { + state.busy = false + } +} + +function initialize() { + if (initialized) return + initialized = true + fetchStatus() + ensurePolling() +} + +function statusLine(label, value) { + return html`
${label}: ${value}
` +} + +export function LongitudinalManeuvers() { + initialize() + + return html` ++ Run the longitudinal maneuver suite from your phone and monitor progress live. +
+ +Loading status...
` : ""} + ${() => state.error ? html`${state.error}
` : ""} + + ${() => state.data ? html` +Current Maneuver: ${state.data.maneuver || "n/a"}
+Popup Text: ${state.data.uiText1 || ""} ${state.data.uiText2 ? `| ${state.data.uiText2}` : ""}
+No steps logged yet.
`} +