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
28 changes: 19 additions & 9 deletions src/components/layout/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type BoardSummary,
type WirelessBoardInfo,
} from "@/components/widgets/BoardStatusWidget";
import { AltitudeTape } from "@/components/widgets/AltitudeTape";

import { useBackendConnection } from "@/hooks/useBackendConnection";
import { useBoardConnection } from "@/hooks/useBoardConnection";
Expand Down Expand Up @@ -98,7 +99,7 @@ export const Dashboard: FC = () => {
return (
<div className="flex h-screen w-full no-scrollbar">
{/* Left Side - 3D Model */}
<div className="w-1/3 h-screen flex items-center justify-center">
<div className="flex h-screen min-w-[320px] flex-shrink basis-1/3 items-stretch justify-center">
<div className="relative opacity-75 hover:opacity-95">
<div
className={`fixed top-4 left-4 z-49 p-2 size-10 rounded-full ${
Expand All @@ -114,17 +115,26 @@ export const Dashboard: FC = () => {
{darkMode ? <BrightnessIcon /> : <BrightnessIcon className="fill-base-200" />}
</button>
</div>
<MyThree
w={sensorData.w}
x={sensorData.x}
y={sensorData.y}
z={sensorData.z}
lightMode={darkMode}
/>

<div className="flex h-full w-full items-stretch">
<div className="min-w-0 flex-1">
<MyThree
w={sensorData.w}
x={sensorData.x}
y={sensorData.y}
z={sensorData.z}
lightMode={darkMode}
/>
</div>

<div className="relative flex h-full flex-shrink-0 items-stretch bg-base transition-colors duration-700 dark:bg-base">
<AltitudeTape altitudeMeters={sensorData.alt} />
</div>
</div>
</div>

{/* Right Side - Data Panels */}
<div className="w-2/3 h-screen overflow-y-auto bg-base dark:bg-base p-6 no-scrollbar transition-colors duration-700">
<div className="h-screen min-w-0 flex-1 overflow-y-auto bg-base p-6 no-scrollbar transition-colors duration-700 dark:bg-base">
<div className="flex w-full space-x-6">
<SensorReadingWidget sensorData={sensorData} />
<BoardStatusWidget
Expand Down
96 changes: 96 additions & 0 deletions src/components/widgets/AltitudeTape.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { type FC, useEffect, useState } from "react";
import { POLLING_INTERVAL_MS } from "@/hooks/useSensorData";
import { ConversionFactors, altitudeHandler, AltitudeMode } from "@/utils/units/units"

/* -- Constants -- */
const DEFAULT_ALTITUDE_MAXIMUM_METERS = 10000 / ConversionFactors.METERS_TO_FEET;
const GROUND_BAND_REM = 2; // reserved space at the bottom for the reading
const TOP_CLEARANCE_REM = 3.33; // reserved space at the top so the pill can't breach it
const TRACK_TRANSITION_MS = POLLING_INTERVAL_MS; // small buffer so transitions finish before the next poll lands

/* -- Model Functions -- */
export const getAltitudeMaximumMeters = (override?: number): number => {
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
return override;
}

/* Rev 3's barometer has a different max altitude, so we could
apply scaling based on the platform using this function. This
is postponed for now, though. */

return DEFAULT_ALTITUDE_MAXIMUM_METERS;
};

/* -- View & Presenter -- */
export interface AltitudeTapeProps {
altitudeMeters: number;
altitudeMaximumMeters?: number;
}

export const AltitudeTape: FC<AltitudeTapeProps> = ({
altitudeMeters,
altitudeMaximumMeters,
}) => {
const [hasReceivedReading, setHasReceivedReading] = useState(false);

useEffect(() => {
if (altitudeMeters > 0 && !hasReceivedReading) {
setHasReceivedReading(true);
}
}, [altitudeMeters, hasReceivedReading]);

// Inline style rather than a Tailwind class, since the duration is
// computed at runtime from POLLING_INTERVAL_MS and won't survive
// Tailwind's JIT class scanning as a template string.
const trackTransitionStyle = hasReceivedReading
? { transition: `bottom ${TRACK_TRANSITION_MS}ms` }
: {};

if( altitudeHandler.mode === AltitudeMode.QFE ) {
altitudeMeters -= altitudeHandler.referenceElevation;
}
const maximum = getAltitudeMaximumMeters(altitudeMaximumMeters);
const safeAltitude = Number.isFinite(altitudeMeters) ? altitudeMeters : 0;
const clampedAltitude = Math.max(0, Math.min(safeAltitude, maximum));
const fillPercent = maximum > 0 ? (clampedAltitude / maximum) * 100 : 0;
const clampedFillPercent = Math.max(0, Math.min(fillPercent, 100));
const fillFraction = clampedFillPercent / 100;

// Both the bar and the pill sit within the space above the ground band,
// so 0% lands right at the top of the ground band instead of the very
// bottom of the container. The top of the container is also reserved,
// meaning the pill position will latch at maxAltitude and keep from
// blocking the unit readout.
const trackPosition = `calc(${fillFraction} * (100% - ${GROUND_BAND_REM}rem - ${TOP_CLEARANCE_REM}rem) + ${GROUND_BAND_REM}rem)`;

// Widget Layout
return (
<div className="relative flex h-full w-20 flex-col items-center justify-between rounded-l-none rounded-r-lg font-sans text-xs text-white/90 transition-colors duration-700 shadow-xl">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-l-none rounded-r-lg">
<div className="absolute inset-0 bg-gradient-to-b transition-colors duration-700 to-sky-200 from-blue-400 dark:to-sky-800 dark:from-blue-950" />
<div
className="absolute bottom-0 left-0 right-0 bg-amber-800 transition-colors duration-700 dark:bg-amber-950"
style={{ height: `${GROUND_BAND_REM}rem` }}
/>
<div
className="absolute left-0 right-0 h-[2px] bg-white shadow-[0_0_4px_rgba(255,255,255,0.8)]"
style={{ bottom: trackPosition, ...trackTransitionStyle }}
/>
</div>

<div className="relative z-10 flex flex-col items-center pt-2 transition-colors duration-700 text-slate-800 dark:text-slate-400">
<span className="font-medium leading-tight">Altitude</span>
<span className="text-[10px] font-normal leading-tight">({altitudeHandler.getReferenceMode()})</span>
</div>

<div
className="absolute left-0 right-0 z-20 flex justify-center"
style={{ bottom: trackPosition, transform: "translateY(50%)", ...trackTransitionStyle }}
>
<div className="rounded-full border border-white/25 bg-slate-200 dark:bg-slate-800 px-2 py-1 font-medium transition-colors duration-700 text-black/95 dark:text-white/95">
{altitudeHandler.getDisplayString(altitudeMeters)}
</div>
Comment on lines +91 to +92

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The needle position (trackPosition, driven by clampedAltitude/fillFraction) uses raw altitudeMeters, but this pill renders altitudeHandler.getDisplayString(altitudeMeters), which subtracts referenceElevation in QFE mode. They will disagree by the launch site elevation whenever QFE is active. Both need to go through the exact same conversion.

Second issue: clampedAltitude clamps to a minimum of 0, but altitudeUnits.ts comments state negative altitudes are allowed in QFE.

</div>
</div>
);
};
65 changes: 31 additions & 34 deletions src/components/widgets/SensorReadingWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { FC } from "react";
import ConversionFactors, { altitudeHandler, accelerationHandler } from "@/utils/units/units"

export interface SensorData {
quat_w: number;
quat_x: number;
quat_y: number;
quat_z: number;
w: number;
x: number;
y: number;
z: number;
alt: number;
long: number;
lat: number;
acc_z: number;
acc_x: number;
roll_rate: number;
}

Expand All @@ -18,51 +19,39 @@ export interface SensorReadingWidgetProps {

interface DataItem {
label: string;
value: number;
value: string;
}

interface DataGroupProps {
title: string;
data: DataItem[];
}

function padNumber(value: number, length = 6): string {
const str = String(value);
if (str.startsWith("-")) {
return `-${str.slice(1).padStart(length, "0")}`;
}
return str.padStart(length, "0");
}

function formatValue(value: number): string {
return value === 0 ? "0" : padNumber(value);
}

const DataGroup: FC<DataGroupProps> = ({ title, data }) => (
<div className="mt-2 transition-colors duration-700">
<p className="text-lg font-bold">{title}</p>
<div className="text-sm">
{data.map(({ label, value }) => (
<div key={label} className="flex justify-between w-full">
<span>{label}</span>
<span>{formatValue(value)}</span>
<span>{value}</span>
</div>
))}
</div>
</div>
);

export const SensorReadingWidget: FC<SensorReadingWidgetProps> = ({ sensorData }) => {
const {
quat_w,
quat_x,
quat_y,
quat_z,
alt,
long,
const {
w, /* unit quaternions (orientation) */
x,
y,
z,
alt, /* alt (m) */
long, /* last GPS ping */
lat,
acc_z,
roll_rate,
acc_x, /* accel on thrust axis */
roll_rate /* rate of body roll */
} = sensorData;

return (
Expand All @@ -72,17 +61,25 @@ export const SensorReadingWidget: FC<SensorReadingWidgetProps> = ({ sensorData }
<DataGroup
title="Gyroscope"
data={[
{ label: "W", value: quat_w },
{ label: "X", value: quat_x },
{ label: "Y", value: quat_y },
{ label: "Z", value: quat_z },
{ label: "W", value: w.toFixed(4) }, // unitless
{ label: "X", value: x.toFixed(4) }, // unitless
{ label: "Y", value: y.toFixed(4) }, // unitless
{ label: "Z", value: z.toFixed(4) }, // unitless
]}
/>
<DataGroup
title="Location"
data={[
{ label: "latitude", value: lat },
{ label: "longitude", value: long },
{ label: "latitude", value: lat.toFixed(5) + " deg" }, // units are fixed
{ label: "longitude", value: long.toFixed(5) + " deg" }, // units are fixed
]}
/>
<DataGroup
title="Vehicle Dynamics"
data={[
{ label: "alt", value: altitudeHandler.getDisplayString(alt) },
{ label: "acc_x", value: accelerationHandler.getDisplayString(acc_x) },
{ label: "roll_rate", value: roll_rate.toFixed(2) + " deg/s" }, // no system units yet
]}
/>
</div>
Expand Down
51 changes: 25 additions & 26 deletions src/hooks/useSensorData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { api } from "@/utils/api";
import { MockFlight } from "@/utils/mock";
import type { SensorData } from "@/components/widgets/SensorReadingWidget";

/** Used for renderers to determine the fastest possible update rate */
export const POLLING_INTERVAL_MS = 40;

/**
* Raw sensor payload shape coming from the backend / mock flight source.
* Field names mirror the device's wire format before conversion to the
Expand All @@ -15,24 +18,22 @@ interface RawSensorPacket {
alt?: number;
long?: number;
lat?: number;
acc_z?: number;
acc_x?: number;
roll_rate?: number;
/** Present when the mock source returns an array instead of a single packet. */
length?: number;
}

const POLLING_INTERVAL_MS = 40;

const INITIAL_SENSOR_DATA: SensorData = {
quatW: 1,
quatX: 0,
quatY: 0,
quatZ: 0,
altitude: 0,
longitude: 0,
latitude: 0,
accelerationZ: 0,
rollRate: 0,
w: 1,
x: 0,
y: 0,
z: 0,
alt: 0,
long: 0,
lat: 0,
acc_x: 0,
roll_rate: 0,
};

/** Formats a raw numeric value, falling back to the previous value when invalid. */
Expand All @@ -43,24 +44,22 @@ function toFixedOrPrevious(value: number | undefined, previous: number, digits =
return Number(Number(value).toFixed(digits));
}

function parseSensorData(data: RawSensorPacket | null, prevState: SensorData): SensorData {
function parseSensorData(data: RawSensorPacket | null | undefined, prevState: SensorData): SensorData {
if (!data) {
return prevState;
}

return {
quatW: toFixedOrPrevious(data.quat_w, prevState.quatW, 6),
quatX: toFixedOrPrevious(data.quat_x, prevState.quatX, 6),
quatY: toFixedOrPrevious(data.quat_y, prevState.quatY, 6),
quatZ: toFixedOrPrevious(data.quat_z, prevState.quatZ, 6),

altitude: toFixedOrPrevious(data.alt, prevState.altitude),

longitude: data.lat !== 0 || data.long !== 0 ? (data.long ?? prevState.longitude) : prevState.longitude,
latitude: data.lat !== 0 || data.long !== 0 ? (data.lat ?? prevState.latitude) : prevState.latitude,

accelerationZ: toFixedOrPrevious(data.acc_z, prevState.accelerationZ),
rollRate: toFixedOrPrevious(data.roll_rate, prevState.rollRate),
w: toFixedOrPrevious(data.quat_w, prevState.w, 6),
x: toFixedOrPrevious(data.quat_x, prevState.x, 6),
y: toFixedOrPrevious(data.quat_y, prevState.y, 6),
z: toFixedOrPrevious(data.quat_z, prevState.z, 6),

alt: toFixedOrPrevious(data.alt, prevState.alt),
long: data.lat !== 0 || data.long !== 0 ? (data.long ?? prevState.long) : prevState.long,
lat: data.lat !== 0 || data.long !== 0 ? (data.lat ?? prevState.lat) : prevState.lat,
acc_x: toFixedOrPrevious(data.acc_x, prevState.acc_x),
roll_rate: toFixedOrPrevious(data.roll_rate, prevState.roll_rate),
};
Comment on lines +62 to 63

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against sensor_axis_remap() in mod/sensor/sensor.c — acc_x arrives mount-corrected. SDECv2 (Parser/telemetry.py, commit 75e3733) emits as acc_x, too.

One thing for the display side: accel offset compensation is disabled in firmware (/* Do not use offset compensation for accel to preserve gravity */), so acc_x reads around 1g @ rest instead of 0. Again, another FYI for reference, so it isn't flagged as a bug.

Will note though that the combination of acc_x?: number & toFixedOrPreviouscreates a silent failure mode where if this field goes missing (e.g. from a mismatched wire format or a sensor dropout), the dashboard will just hold the last known value indefinitely w/ zero warning. I assume you could possibly add console.warn or something visual.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gravity compensation also in review

}

Expand All @@ -74,7 +73,7 @@ export const useSensorData = (

const fetchData = useCallback(async () => {
try {
const result: RawSensorPacket = mock
const result: RawSensorPacket | undefined = mock
? await MockFlight.getSensorData(rowCount)
: (await api.getSensorData()).data;

Expand Down
Loading