Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Prefer bench testing and disconnected telemetry validation before using the soft
- **Ground target estimation** (desktop) — image-center target with GeoTIFF DEM ray marching, map marker, line-of-sight, and sample-log export
- **Preflight health advisory** — sensor and link health checks with configurable thresholds
- **Session logging** and reset for new flights
- **Onboarding tour** — first-run walkthrough of link controls, telemetry sidebar, map, camera, and activity log; skip anytime; restart from the **?** button in the top bar (`localStorage` keys `uav-gcs.onboarding.*`)
- **Replay & Simulation** — frontend-only, read-only telemetry sources that drive the same dashboard without hardware: replay recorded `.jsonl`/`.json` logs (start/pause/seek/step, speed and timing modes) or run deterministic seeded simulations. See [`docs/replay-mode.md`](docs/replay-mode.md) and [`docs/adr/0003-frontend-only-replay-simulation.md`](docs/adr/0003-frontend-only-replay-simulation.md)
- **Shared data model** [`TelemetryState`](packages/shared/src/index.ts) for desktop and browser

Expand Down Expand Up @@ -192,6 +193,10 @@ pnpm --filter @uav-ground-control-station/web preview

## Operator notes

### Onboarding tour

On first launch (after the optional splash screen), a guided tour highlights the top bar, telemetry sidebar, map, camera panel, and activity log. Click **Skip tour** to dismiss it; completion is stored per runtime in `localStorage` (`uav-gcs.onboarding.completed`, `uav-gcs.onboarding.version`). Desktop, browser dev, and Hosted Web App each keep their own completion state. Reopen the tour anytime with the **?** button in the top bar.

### Serial link

| Scenario | Typical baud rate |
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@uav-ground-control-station/desktop",
"version": "0.2.35",
"version": "0.2.36",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "uav-ground-control-station"
version = "0.2.35"
version = "0.2.36"
description = "Native desktop shell for UAV Ground Control Station"
authors = ["F. Eber"]
license = "GPL-3.0-only"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "UAV Ground Control Station",
"version": "0.2.35",
"version": "0.2.36",
"identifier": "com.uav.ground-control-station",
"build": {
"beforeDevCommand": "pnpm --filter @uav-ground-control-station/web dev",
Expand Down
5 changes: 3 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
"test:watch": "vitest"
},
"dependencies": {
"@uav-ground-control-station/shared": "workspace:*",
"@uav-ground-control-station/target-estimation": "workspace:*",
"@tauri-apps/api": "latest",
"@tauri-apps/plugin-dialog": "latest",
"@uav-ground-control-station/shared": "workspace:*",
"@uav-ground-control-station/target-estimation": "workspace:*",
"@vitejs/plugin-react": "latest",
"autoprefixer": "latest",
"driver.js": "^1.4.0",
"maplibre-gl": "latest",
"postcss": "latest",
"react": "latest",
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import { VideoPanel } from "./components/VideoPanel";
import { ActivityLogPanel } from "./components/ActivityLogPanel";
import { ReplaySimPanel } from "./components/ReplaySimPanel";
import { SplashScreen } from "./components/SplashScreen";
import { OnboardingTour } from "./components/OnboardingTour";
import { getRemoteSerialControlApiBanner } from "./lib/apiSafety";
import { webSerialUnsupportedReason } from "./link/webSerialSupport";

const ENABLE_SPLASH_SCREEN = import.meta.env.VITE_ENABLE_SPLASH_SCREEN !== "false";

export function App() {
const [showSplash, setShowSplash] = useState(ENABLE_SPLASH_SCREEN);
const [dashboardReady, setDashboardReady] = useState(!ENABLE_SPLASH_SCREEN);
const [tourRestartToken, setTourRestartToken] = useState(0);
const {
telemetry,
status,
Expand Down Expand Up @@ -122,6 +125,7 @@ export function App() {
onReset={resetAll}
onStartLogging={startLogging}
onStopLogging={stopLogging}
onRestartTour={() => setTourRestartToken((token) => token + 1)}
/>

{cloudUnsupported && <CloudUnsupportedBanner reason={cloudUnsupported} />}
Expand Down Expand Up @@ -166,7 +170,15 @@ export function App() {
)}
</div>
</div>
{showSplash && <SplashScreen onDone={() => setShowSplash(false)} />}
{showSplash && (
<SplashScreen
onDone={() => {
setShowSplash(false);
setDashboardReady(true);
}}
/>
)}
<OnboardingTour active={dashboardReady} runtimeMode={runtimeMode} restartToken={tourRestartToken} />
</>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ActivityLogPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function ActivityLogPanel({ logs, messages, onClear }: ActivityLogPanelPr
const latestWarning = logs.find((entry) => entry.level === "warning" || entry.level === "error");

return (
<section className="absolute bottom-4 left-[340px] z-20 w-[520px] overflow-hidden rounded-xl border border-cyan-300/20 bg-slate-950/90 shadow-glow backdrop-blur">
<section data-tour="activity-log" className="absolute bottom-4 left-[340px] z-20 w-[520px] overflow-hidden rounded-xl border border-cyan-300/20 bg-slate-950/90 shadow-glow backdrop-blur">
<header className="flex items-center justify-between border-b border-line px-3 py-2">
<button className="text-left" onClick={() => setOpen((value) => !value)}>
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-200">Activity Log</div>
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/components/GroundTargetPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ export function GroundTargetPanel({
const qualityTone = estimate ? qualityToTone(estimate.quality) : ("neutral" as const);

return (
<Panel
title="Ground Target"
{...(sortable ? { sortable: true, onDragStart, onDragEnd } : {})}
>
<div data-tour="ground-target">
<Panel
title="Ground Target"
{...(sortable ? { sortable: true, onDragStart, onDragEnd } : {})}
>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Badge tone={qualityTone}>{estimate?.quality ?? "idle"}</Badge>
{liveOnlyBlocked && <Badge tone="warn">live only</Badge>}
Expand Down Expand Up @@ -349,6 +350,7 @@ export function GroundTargetPanel({
</div>
</div>
</Panel>
</div>
);
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/HudOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function HudOverlay({ telemetry, stale = false, compact = false }: HudOve

return (
<div
data-tour="attitude-hud"
className={`relative pointer-events-none select-none font-mono text-slate-100 ${
stale ? "opacity-60 saturate-50" : ""
}`}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/MapBasemapSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface MapBasemapSwitcherProps {
export function MapBasemapSwitcher({ value, onChange }: MapBasemapSwitcherProps) {
return (
<div
data-tour="map-basemap"
className="map-basemap-switcher absolute right-4 top-4 z-10 flex"
role="group"
aria-label="Map basemap"
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/MapPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS
}, [groundTarget, droneLngLat, mapReady, styleEpoch, telemetryStale]);

return (
<main className="relative min-w-0 flex-1">
<main data-tour="map" className="relative min-w-0 flex-1">
<div ref={containerRef} className="h-full w-full bg-slate-950" />

{basemapSwitcherEnabled && <MapBasemapSwitcher value={basemapId} onChange={handleBasemapChange} />}
Expand All @@ -204,7 +204,7 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS
</div>

{!isControlled && (
<button className="btn-secondary absolute bottom-4 left-4" onClick={() => setTrack([])}>
<button data-tour="clear-track" className="btn-secondary absolute bottom-4 left-4" onClick={() => setTrack([])}>
Clear Track
</button>
)}
Expand Down
137 changes: 137 additions & 0 deletions apps/web/src/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useEffect, useRef } from "react";
import { driver, type DriveStep } from "driver.js";
import "driver.js/dist/driver.css";
import { buildOnboardingSteps } from "../lib/onboardingSteps";
import {
clearOnboardingCompletion,
hasCompletedOnboarding,
markOnboardingComplete,
prefersReducedMotion
} from "../lib/onboarding";
import type { RuntimeMode } from "../lib/runtimeMode";

interface OnboardingTourProps {
active: boolean;
runtimeMode: RuntimeMode;
restartToken: number;
}

export function OnboardingTour({ active, runtimeMode, restartToken }: OnboardingTourProps) {
const driverRef = useRef<ReturnType<typeof driver> | null>(null);

useEffect(() => {
if (!active) {
return;
}

driverRef.current?.destroy();

const manualRestart = restartToken > 0;
if (manualRestart) {
clearOnboardingCompletion();
} else if (hasCompletedOnboarding()) {
return;
}

const timer = window.setTimeout(() => {
const steps = filterVisibleSteps(buildOnboardingSteps(runtimeMode));
if (steps.length === 0) {
return;
}

const driverObj = driver({
animate: !prefersReducedMotion(),
showProgress: true,
progressText: "{{current}} of {{total}}",
nextBtnText: "Next",
prevBtnText: "Back",
doneBtnText: "Done",
allowClose: true,
overlayOpacity: 0.62,
stagePadding: 8,
stageRadius: 10,
popoverOffset: 14,
popoverClass: "gcs-driver-popover",
overlayColor: "#020617",
showButtons: ["previous", "next"],
steps,
onPopoverRender: (popover) => {
styleTourSkipControl(popover);
clampPopoverToViewport(popover.wrapper);
},
onDestroyed: () => {
markOnboardingComplete();
driverRef.current = null;
}
});

driverRef.current = driverObj;
driverObj.drive();
}, 450);

return () => {
window.clearTimeout(timer);
driverRef.current?.destroy();
driverRef.current = null;
};
}, [active, runtimeMode, restartToken]);

return null;
}

function filterVisibleSteps(steps: DriveStep[]): DriveStep[] {
return steps.filter((step) => {
if (!step.element) {
return true;
}

if (typeof step.element === "function") {
return Boolean(step.element());
}

if (typeof step.element === "string") {
return document.querySelector(step.element) !== null;
}

return document.contains(step.element);
});
}

function styleTourSkipControl(popover: {
closeButton: HTMLButtonElement;
title: HTMLElement;
wrapper: HTMLElement;
}): void {
popover.closeButton.textContent = "Skip";
popover.closeButton.setAttribute("aria-label", "Skip onboarding tour");
popover.closeButton.classList.add("gcs-driver-skip");

popover.title.classList.add("gcs-driver-popover-title-row");
popover.wrapper.classList.add("gcs-driver-popover-shell");
}

function clampPopoverToViewport(wrapper: HTMLElement, margin = 12): void {
const rect = wrapper.getBoundingClientRect();
let left = rect.left;
let top = rect.top;

if (rect.right > window.innerWidth - margin) {
left -= rect.right - (window.innerWidth - margin);
}
if (rect.left < margin) {
left += margin - rect.left;
}
if (rect.bottom > window.innerHeight - margin) {
top -= rect.bottom - (window.innerHeight - margin);
}
if (rect.top < margin) {
top += margin - rect.top;
}

if (left !== rect.left) {
wrapper.style.left = `${Math.round(left)}px`;
}
if (top !== rect.top) {
wrapper.style.top = `${Math.round(top)}px`;
}
}
35 changes: 20 additions & 15 deletions apps/web/src/components/TelemetrySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function TelemetrySidebar({

return (
<aside
data-tour="telemetry-sidebar"
className={`z-10 flex w-[320px] shrink-0 flex-col gap-3 overflow-y-auto border-r border-cyan-300/10 bg-slate-950/78 p-3 backdrop-blur ${
telemetryStale ? "opacity-70 saturate-75" : ""
}`}
Expand All @@ -74,21 +75,25 @@ export function TelemetrySidebar({
</div>
</div>

<PreflightHealthCard health={preflight} />
<div data-tour="preflight-health">
<PreflightHealthCard health={preflight} />
</div>

<Panel title="Alerts">
{alerts.length === 0 ? (
<div className="text-sm text-emerald-200">No active alerts</div>
) : (
<div className="flex flex-wrap gap-2">
{alerts.map((alert) => (
<Badge key={alert.label} tone={alert.level === "critical" ? "bad" : "warn"}>
{alert.label}
</Badge>
))}
</div>
)}
</Panel>
<div data-tour="alerts">
<Panel title="Alerts">
{alerts.length === 0 ? (
<div className="text-sm text-emerald-200">No active alerts</div>
) : (
<div className="flex flex-wrap gap-2">
{alerts.map((alert) => (
<Badge key={alert.label} tone={alert.level === "critical" ? "bad" : "warn"}>
{alert.label}
</Badge>
))}
</div>
)}
</Panel>
</div>

{view === "instruments" ? (
<TelemetryInstruments
Expand Down Expand Up @@ -273,7 +278,7 @@ function renderTextCard(id: SidebarCardId, ctx: TextRenderContext, drag: Sidebar

function SidebarViewToggle({ view, onChange }: { view: SidebarView; onChange: (view: SidebarView) => void }) {
return (
<div className="flex rounded-lg border border-cyan-300/20 bg-slate-900/80 p-0.5 font-mono text-[10px]">
<div data-tour="sidebar-view-toggle" className="flex rounded-lg border border-cyan-300/20 bg-slate-900/80 p-0.5 font-mono text-[10px]">
<button
type="button"
className={toggleClass(view === "text")}
Expand Down
Loading