From ccd313cb9605a44960669893819472368221fa06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:20:50 +0000 Subject: [PATCH] Prefer device-reported speed on the Analysis tab, with unit settings per device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Analysis tab's speed chart and average/max cards were computed entirely by differentiating consecutive GPS position samples, which amplifies normal GPS jitter into wild spikes (e.g. a boat under steady autopilot showing a 21mph "maximum speed"). Devices already send their own reported speed in Events.data.location.speed, but it was never read. Add two per-device settings (Admin -> Devices): "Input speed" (the unit the device's own reported speed is sent in — knots/km/h/m/s/mph, or "not reported" to keep today's behaviour) and "Display speed" (the unit shown throughout the Analysis tab). The Analysis loader now prefers the device's reported speed per point when configured, falling back to the position-derived calculation — with a visible warning — only for points that lack a reported speed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Q1tFbZfhQvqCNtbqopqTbM --- .../AnalysisMap/AnalysisMap.client.tsx | 14 +- .../components/AnalysisMap/AnalysisMap.tsx | 2 + .../app/components/AnalysisMap/speedColor.ts | 40 +- website/app/routes/admin/devices.tsx | 103 +++- website/app/routes/date/analysis.tsx | 260 ++++++--- website/app/utils/speedUnits.ts | 32 ++ .../migrations/0010_flashy_amphibian.sql | 2 + .../migrations/meta/0010_snapshot.json | 523 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + website/database/schema/Devices.ts | 10 + 10 files changed, 894 insertions(+), 99 deletions(-) create mode 100644 website/app/utils/speedUnits.ts create mode 100644 website/database/migrations/0010_flashy_amphibian.sql create mode 100644 website/database/migrations/meta/0010_snapshot.json diff --git a/website/app/components/AnalysisMap/AnalysisMap.client.tsx b/website/app/components/AnalysisMap/AnalysisMap.client.tsx index 3580a36..6be4256 100644 --- a/website/app/components/AnalysisMap/AnalysisMap.client.tsx +++ b/website/app/components/AnalysisMap/AnalysisMap.client.tsx @@ -12,6 +12,7 @@ import { } from "react-leaflet"; import { theme } from "~/root"; import { formatDateTimeMed } from "~/utils/dateTime"; +import { fromMetersPerSecond, SPEED_UNIT_LABELS, type SpeedUnit } from "~/utils/speedUnits"; import { createRestrictedViewportBounds, mapPerformanceConfig, @@ -35,7 +36,7 @@ export type AnalysisRouteSegment = { timeDeltaSeconds: number; distanceMeters: number; speedMps: number; - speedMph: number; + speedDisplay: number; isStop: boolean; positions: [number, number][]; }; @@ -53,11 +54,13 @@ export function AnalysisMap(props: { points: AnalysisRoutePoint[]; segments: AnalysisRouteSegment[]; highlightedPointId?: number | null; + speedUnit?: SpeedUnit; }) { const config = mapPerformanceConfig.analysis; + const speedUnit = props.speedUnit ?? "mph"; const speedRange = getSpeedRange( - props.segments.map((segment) => segment.speedMph), + props.segments.map((segment) => segment.speedDisplay), ); const highlightedPoint = @@ -101,7 +104,7 @@ export function AnalysisMap(props: { key={segment.id} positions={segment.positions} pathOptions={{ - color: speedToColor(segment.speedMph, speedRange), + color: speedToColor(segment.speedDisplay, speedRange), weight: 5, }} /> @@ -119,7 +122,10 @@ export function AnalysisMap(props: { {formatDateTimeMed(highlightedPoint.timestamp)}
- {(highlightedPoint.speedMps * 2.2369362921).toFixed(1)} mph + {fromMetersPerSecond(highlightedPoint.speedMps, speedUnit).toFixed( + 1, + )}{" "} + {SPEED_UNIT_LABELS[speedUnit]}
) : null} diff --git a/website/app/components/AnalysisMap/AnalysisMap.tsx b/website/app/components/AnalysisMap/AnalysisMap.tsx index c0073f8..e918d3e 100644 --- a/website/app/components/AnalysisMap/AnalysisMap.tsx +++ b/website/app/components/AnalysisMap/AnalysisMap.tsx @@ -1,5 +1,6 @@ import { Center } from "@mantine/core"; import { ClientOnly } from "remix-utils/client-only"; +import type { SpeedUnit } from "~/utils/speedUnits"; import { AnalysisMap as AnalysisMapClient, type AnalysisRoutePoint, @@ -10,6 +11,7 @@ export function AnalysisMap(props: { points: AnalysisRoutePoint[]; segments: AnalysisRouteSegment[]; highlightedPointId?: number | null; + speedUnit?: SpeedUnit; }) { return ( }> diff --git a/website/app/components/AnalysisMap/speedColor.ts b/website/app/components/AnalysisMap/speedColor.ts index e0f1238..fe39adc 100644 --- a/website/app/components/AnalysisMap/speedColor.ts +++ b/website/app/components/AnalysisMap/speedColor.ts @@ -7,8 +7,8 @@ const PALETTE_STOPS: Array<{ t: number; color: RgbColor }> = [ ]; export type SpeedRange = { - minMph: number; - maxMph: number; + min: number; + max: number; }; const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); @@ -19,23 +19,23 @@ const formatHexChannel = (value: number) => const rgbToHex = ([red, green, blue]: RgbColor) => `#${formatHexChannel(red)}${formatHexChannel(green)}${formatHexChannel(blue)}`; -export const getSpeedRange = (speedMphValues: number[]): SpeedRange => { - const validSpeeds = speedMphValues.filter( - (speedMph) => Number.isFinite(speedMph) && speedMph >= 0, +export const getSpeedRange = (speedValues: number[]): SpeedRange => { + const validSpeeds = speedValues.filter( + (speedValue) => Number.isFinite(speedValue) && speedValue >= 0, ); if (validSpeeds.length === 0) { - return { minMph: 0, maxMph: 1 }; + return { min: 0, max: 1 }; } - const minMph = Math.min(...validSpeeds); - const maxMph = Math.max(...validSpeeds); + const min = Math.min(...validSpeeds); + const max = Math.max(...validSpeeds); - if (Math.abs(maxMph - minMph) < 1e-9) { - return { minMph, maxMph: minMph + 1 }; + if (Math.abs(max - min) < 1e-9) { + return { min, max: min + 1 }; } - return { minMph, maxMph }; + return { min, max }; }; const interpolateRgb = ( @@ -65,27 +65,27 @@ const getPaletteColorAt = (normalizedValue: number) => { return rgbToHex(PALETTE_STOPS[PALETTE_STOPS.length - 1].color); }; -export const speedToColor = (speedMph: number, speedRange: SpeedRange) => { - const range = speedRange.maxMph - speedRange.minMph; - const normalized = range > 0 ? (speedMph - speedRange.minMph) / range : 0; +export const speedToColor = (speedValue: number, speedRange: SpeedRange) => { + const range = speedRange.max - speedRange.min; + const normalized = range > 0 ? (speedValue - speedRange.min) / range : 0; return getPaletteColorAt(normalized); }; export const buildLegendTicks = (speedRange: SpeedRange, tickCount = 5) => { if (tickCount < 2) { - const speedMph = speedRange.minMph; - return [{ speedMph, color: speedToColor(speedMph, speedRange) }]; + const speedValue = speedRange.min; + return [{ speedValue, color: speedToColor(speedValue, speedRange) }]; } - const span = speedRange.maxMph - speedRange.minMph; + const span = speedRange.max - speedRange.min; return Array.from({ length: tickCount }, (_, index) => { const ratio = index / (tickCount - 1); - const speedMph = speedRange.minMph + span * ratio; + const speedValue = speedRange.min + span * ratio; return { - speedMph, - color: speedToColor(speedMph, speedRange), + speedValue, + color: speedToColor(speedValue, speedRange), }; }); }; diff --git a/website/app/routes/admin/devices.tsx b/website/app/routes/admin/devices.tsx index 8dd3700..725d1ba 100644 --- a/website/app/routes/admin/devices.tsx +++ b/website/app/routes/admin/devices.tsx @@ -19,8 +19,24 @@ import { import { AccessPasswords } from "~/database/schema/AccessPasswords"; import { Events } from "~/database/schema/Events"; import { Devices } from "~/database/schema/Devices"; +import { + isSpeedUnit, + SPEED_UNIT_OPTIONS, + type SpeedUnit, +} from "~/utils/speedUnits"; import type { Route } from "./+types/devices"; +const DEFAULT_DISPLAY_SPEED_UNIT: SpeedUnit = "mph"; +const NO_INPUT_SPEED_UNIT_VALUE = "none"; + +const INPUT_SPEED_UNIT_OPTIONS = [ + { + value: NO_INPUT_SPEED_UNIT_VALUE, + label: "Not reported (use calculated speed)", + }, + ...SPEED_UNIT_OPTIONS, +]; + export const meta: MetaFunction = () => { return [{ title: "Device Admin" }]; }; @@ -49,6 +65,23 @@ const parseDeviceIconInput = (rawIcon: FormDataEntryValue | null) => { return icon; }; +const parseInputSpeedUnitInput = (rawUnit: FormDataEntryValue | null) => { + const unit = typeof rawUnit === "string" ? rawUnit : ""; + if (unit === NO_INPUT_SPEED_UNIT_VALUE || unit === "") return null; + if (!isSpeedUnit(unit)) { + throw new Error("Invalid input speed unit"); + } + return unit; +}; + +const parseDisplaySpeedUnitInput = (rawUnit: FormDataEntryValue | null) => { + const unit = typeof rawUnit === "string" ? rawUnit : ""; + if (!isSpeedUnit(unit)) { + throw new Error("Invalid display speed unit"); + } + return unit; +}; + const parseDeviceIdInput = (rawId: FormDataEntryValue | null) => { const id = Number(rawId); if (!Number.isInteger(id) || id <= 0) { @@ -123,6 +156,8 @@ export async function loader({ context }: Route.LoaderArgs) { name: Devices.name, matchId: Devices.matchId, icon: Devices.icon, + inputSpeedUnit: Devices.inputSpeedUnit, + displaySpeedUnit: Devices.displaySpeedUnit, passwordCount: sql`coalesce(${passwordCounts.passwordCount}, 0)`, eventCount: sql`coalesce(${eventCounts.eventCount}, 0)`, }) @@ -146,10 +181,18 @@ export async function action({ context, request }: Route.ActionArgs) { (formData.get("matchId") as string | null) ?? "", ); const icon = parseDeviceIconInput(formData.get("icon")); + const inputSpeedUnit = parseInputSpeedUnitInput( + formData.get("inputSpeedUnit"), + ); + const displaySpeedUnit = parseDisplaySpeedUnitInput( + formData.get("displaySpeedUnit"), + ); await ensureNameIsUnique(db, name); await ensureMatcherIsUnique(db, matchId); - await db.insert(Devices).values({ name, matchId, icon }); + await db + .insert(Devices) + .values({ name, matchId, icon, inputSpeedUnit, displaySpeedUnit }); return { success: true }; } @@ -162,12 +205,18 @@ export async function action({ context, request }: Route.ActionArgs) { (formData.get("matchId") as string | null) ?? "", ); const icon = parseDeviceIconInput(formData.get("icon")); + const inputSpeedUnit = parseInputSpeedUnitInput( + formData.get("inputSpeedUnit"), + ); + const displaySpeedUnit = parseDisplaySpeedUnitInput( + formData.get("displaySpeedUnit"), + ); await ensureNameIsUnique(db, name, id); await ensureMatcherIsUnique(db, matchId, id); await db .update(Devices) - .set({ name, matchId, icon }) + .set({ name, matchId, icon, inputSpeedUnit, displaySpeedUnit }) .where(eq(Devices.id, id)); return { success: true }; } @@ -211,8 +260,8 @@ export default function Page({ loaderData }: Route.ComponentProps) { Device Administration - Manage device names, matchers, and map icons used to connect and display - incoming webhook data. + Manage device names, matchers, map icons, and speed units used to + connect and display incoming webhook data.
@@ -231,6 +280,24 @@ export default function Page({ loaderData }: Route.ComponentProps) { allowDeselect={false} required /> +
@@ -241,6 +308,8 @@ export default function Page({ loaderData }: Route.ComponentProps) { Name Icon Matcher + Input speed + Display speed Passwords Events Actions @@ -287,6 +356,32 @@ export default function Page({ loaderData }: Route.ComponentProps) { required /> + + + {device.passwordCount} {device.eventCount} diff --git a/website/app/routes/date/analysis.tsx b/website/app/routes/date/analysis.tsx index fa05752..d499ca0 100644 --- a/website/app/routes/date/analysis.tsx +++ b/website/app/routes/date/analysis.tsx @@ -1,5 +1,6 @@ import { getDb, getPasswordRouteAccess } from "~/routeContext"; import { + Alert, Button, Card, Center, @@ -25,13 +26,23 @@ import { formatDateTimeWithSeconds, toMillisTimestamp, } from "~/utils/dateTime"; +import { + fromMetersPerSecond, + isSpeedUnit, + SPEED_UNIT_LABELS, + toMetersPerSecond, + type SpeedUnit, +} from "~/utils/speedUnits"; import type { Route } from "./+types/analysis"; -const MPS_TO_MPH = 2.2369362921; +const DEFAULT_DISPLAY_SPEED_UNIT: SpeedUnit = "mph"; + +// Below this a point is considered stationary regardless of which speed source produced it. +const STOP_SPEED_THRESHOLD_MPS = 0.5; -const getYAxisStepMph = (maxSpeedMph: number) => { - if (maxSpeedMph <= 10) return 1; - if (maxSpeedMph <= 50) return 5; +const getYAxisStep = (maxSpeedDisplay: number) => { + if (maxSpeedDisplay <= 10) return 1; + if (maxSpeedDisplay <= 50) return 5; return 10; }; @@ -40,8 +51,9 @@ const SpeedChart = memo(function SpeedChart(props: { pointId: number; timestampMillis: number; timestampLabel: string; - speedMph: number; + speedDisplay: number; }>; + speedUnitLabel: string; normalizedChartYAxisMax: number; yAxisTicks: number[]; onHoveredPointIdChange: (pointId: number | null) => void; @@ -57,7 +69,7 @@ const SpeedChart = memo(function SpeedChart(props: { }> | undefined; const entries = payload ?? []; - const speedEntry = entries.find((entry) => entry.name === "speedMph"); + const speedEntry = entries.find((entry) => entry.name === "speedDisplay"); const rawPointId = entries.find((entry) => Number.isFinite(Number(entry?.payload?.pointId)), )?.payload?.pointId; @@ -102,7 +114,7 @@ const SpeedChart = memo(function SpeedChart(props: { : "-"} - Speed: {speedValue.toFixed(1)} mph + Speed: {speedValue.toFixed(1)} {props.speedUnitLabel} ); @@ -116,11 +128,19 @@ const SpeedChart = memo(function SpeedChart(props: { type="default" withGradient={false} fillOpacity={0.35} - series={[{ name: "speedMph", color: "red.7", label: "Speed (mph)" }]} + series={[ + { + name: "speedDisplay", + color: "red.7", + label: `Speed (${props.speedUnitLabel})`, + }, + ]} curveType="linear" withDots={false} withLegend={false} - valueFormatter={(value: number) => `${value.toFixed(1)} mph`} + valueFormatter={(value: number) => + `${value.toFixed(1)} ${props.speedUnitLabel}` + } tickLine="y" withXAxis withYAxis @@ -168,6 +188,7 @@ export async function loader({ context }: Route.LoaderArgs) { timestamp: Schema.Events.timestamp, latitude: Schema.Events.latitude, longitude: Schema.Events.longitude, + data: Schema.Events.data, }) .from(Schema.Events) .where( @@ -240,6 +261,11 @@ export async function loader({ context }: Route.LoaderArgs) { END `; + // Position-derived speed, purely from consecutive GPS fixes. Used as a fallback wherever + // the device itself doesn't report a usable speed, and to figure out which of *those* + // fallback segments are GPS-jitter outliers (see outlierThresholdMph below). Deliberately + // still expressed in mph here rather than the device's display unit — it's an internal + // filtering signal, never shown to the user. const segments = db.$with("segments").as( db .select({ @@ -255,13 +281,9 @@ export async function loader({ context }: Route.LoaderArgs) { timeDeltaSeconds: timeDeltaSecondsExpression.as("time_delta_seconds"), distanceMeters: distanceMetersExpression.as("distance_meters"), speedMps: speedMpsExpression.as("speed_mps"), - speedMph: sql`${speedMpsExpression} * ${MPS_TO_MPH}`.as( + speedMph: sql`${speedMpsExpression} * 2.2369362921`.as( "speed_mph", ), - isStop: - sql`CASE WHEN ${speedMpsExpression} < 0.5 THEN 1 ELSE 0 END`.as( - "is_stop", - ), }) .from(pointsWithPrevious) .where(sql`${pointsWithPrevious.previousPointId} IS NOT NULL`), @@ -274,7 +296,6 @@ export async function loader({ context }: Route.LoaderArgs) { speedMps: segments.speedMps, distanceMeters: segments.distanceMeters, timeDeltaSeconds: segments.timeDeltaSeconds, - isStop: segments.isStop, speedPercentileBucket: sql`NTILE(100) OVER (ORDER BY ${segments.speedMps})`.as( "speed_percentile_bucket", @@ -284,7 +305,7 @@ export async function loader({ context }: Route.LoaderArgs) { .where(sql`${segments.speedMps} >= 0`), ); - const [pointRows, segmentRows, summaryRow] = await Promise.all([ + const [pointRows, segmentRows, summaryRow, deviceRows] = await Promise.all([ db .with(points) .select({ @@ -292,6 +313,7 @@ export async function loader({ context }: Route.LoaderArgs) { timestamp: points.timestamp, latitude: points.latitude, longitude: points.longitude, + data: points.data, }) .from(points) .orderBy(asc(points.timestamp)), @@ -305,7 +327,6 @@ export async function loader({ context }: Route.LoaderArgs) { distanceMeters: segments.distanceMeters, speedMps: segments.speedMps, speedMph: segments.speedMph, - isStop: segments.isStop, previousLatitude: segments.previousLatitude, previousLongitude: segments.previousLongitude, latitude: segments.latitude, @@ -317,7 +338,6 @@ export async function loader({ context }: Route.LoaderArgs) { .with(points, pointsWithPrevious, segments, rankedSegments) .select({ points: sql`(SELECT COUNT(*) FROM points)`.as("points"), - segments: sql`(SELECT COUNT(*) FROM segments)`.as("segments"), outlierThresholdMph: sql` MIN( 120.0, @@ -338,20 +358,53 @@ export async function loader({ context }: Route.LoaderArgs) { 0 ) `.as("chart_speed_cap_mph"), - stopCount: - sql`(SELECT COALESCE(SUM(is_stop), 0) FROM segments)`.as( - "stop_count", - ), - slowestSegmentSpeedMph: sql< - number | null - >`(SELECT MIN(speed_mph) FROM segments)`.as( - "slowest_segment_speed_mph", - ), }) .from(points) .limit(1), + db + .select({ + inputSpeedUnit: Schema.Devices.inputSpeedUnit, + displaySpeedUnit: Schema.Devices.displaySpeedUnit, + }) + .from(Schema.Devices) + .where(eq(Schema.Devices.id, deviceId)) + .limit(1), ]); + const inputSpeedUnit: SpeedUnit | null = isSpeedUnit( + deviceRows[0]?.inputSpeedUnit, + ) + ? deviceRows[0].inputSpeedUnit + : null; + const displaySpeedUnit: SpeedUnit = isSpeedUnit( + deviceRows[0]?.displaySpeedUnit, + ) + ? deviceRows[0].displaySpeedUnit + : DEFAULT_DISPLAY_SPEED_UNIT; + const speedUnitLabel = SPEED_UNIT_LABELS[displaySpeedUnit]; + + // A device's own reported speed usually comes straight off the GPS chip's Doppler + // velocity, not by differencing noisy position fixes, so it's far less jumpy than the + // derived calculation above. Prefer it per-point wherever the device actually reported + // one (a reported value of exactly 0 is indistinguishable from "field absent, ingestion + // defaulted it to 0", so those points still fall back to the derived speed). + const deviceSpeedMpsByPointId = new Map(); + if (inputSpeedUnit) { + for (const point of pointRows) { + const rawSpeed = point.data?.location?.speed; + if ( + typeof rawSpeed === "number" && + Number.isFinite(rawSpeed) && + rawSpeed > 0 + ) { + deviceSpeedMpsByPointId.set( + Number(point.id), + toMetersPerSecond(rawSpeed, inputSpeedUnit), + ); + } + } + } + const pointsWithDerivedSpeed = pointRows.map((point) => ({ id: Number(point.id), timestamp: Number(point.timestamp), @@ -380,8 +433,8 @@ export async function loader({ context }: Route.LoaderArgs) { const timestamp = Number(segment.timestamp); const timeDeltaSeconds = Number(segment.timeDeltaSeconds); const distanceMeters = Number(segment.distanceMeters); - const speedMps = Number(segment.speedMps); - const speedMph = Number(segment.speedMph); + const derivedSpeedMps = Number(segment.speedMps); + const derivedSpeedMph = Number(segment.speedMph); const latitude = Number(segment.latitude); const longitude = Number(segment.longitude); const previousLatitude = @@ -393,6 +446,11 @@ export async function loader({ context }: Route.LoaderArgs) { ? Number(segment.previousLongitude) : longitude; + const deviceSpeedMps = deviceSpeedMpsByPointId.get(pointId) ?? null; + const speedMps = deviceSpeedMps ?? derivedSpeedMps; + const source: "device" | "derived" = + deviceSpeedMps != null ? "device" : "derived"; + return { id: segment.id, pointId, @@ -400,8 +458,10 @@ export async function loader({ context }: Route.LoaderArgs) { timeDeltaSeconds, distanceMeters, speedMps, - speedMph, - isStop: Boolean(segment.isStop), + speedDisplay: fromMetersPerSecond(speedMps, displaySpeedUnit), + isStop: speedMps < STOP_SPEED_THRESHOLD_MPS, + source, + derivedSpeedMph, positions: [ [previousLatitude, previousLongitude], [latitude, longitude], @@ -411,9 +471,12 @@ export async function loader({ context }: Route.LoaderArgs) { .filter( (segment) => Number.isFinite(segment.pointId) && - Number.isFinite(segment.speedMph) && - segment.speedMph >= 0 && - segment.speedMph <= outlierThresholdMph, + Number.isFinite(segment.speedDisplay) && + segment.speedMps >= 0 && + // Device-reported speeds are trusted outright; the outlier cutoff only exists to + // drop GPS-jitter spikes in the derived fallback. + (segment.source === "device" || + segment.derivedSpeedMph <= outlierThresholdMph), ); routeSegments.forEach((segment) => { @@ -428,24 +491,27 @@ export async function loader({ context }: Route.LoaderArgs) { segment.timeDeltaSeconds > 0 ? total + segment.timeDeltaSeconds : total, 0, ); - const totalFilteredDistanceMeters = routeSegments.reduce( - (total, segment) => total + segment.distanceMeters, + // Distance implied by whichever speed source was resolved for the segment, not the raw + // GPS distance — keeps the average consistent with the (possibly device-sourced) speeds + // being averaged, and matches the old distance/time formula exactly when everything is + // derived (speedMps *is* distanceMeters / timeDeltaSeconds in that case). + const totalResolvedDistanceMeters = routeSegments.reduce( + (total, segment) => total + segment.speedMps * segment.timeDeltaSeconds, 0, ); - const filteredAverageSpeedMph = + const filteredAverageSpeedMps = totalFilteredTimeDeltaSeconds > 0 - ? (totalFilteredDistanceMeters / totalFilteredTimeDeltaSeconds) * - MPS_TO_MPH + ? totalResolvedDistanceMeters / totalFilteredTimeDeltaSeconds : 0; - const filteredMaxSpeedMph = routeSegments.reduce( - (max, segment) => Math.max(max, segment.speedMph), + const filteredMaxSpeedMps = routeSegments.reduce( + (max, segment) => Math.max(max, segment.speedMps), 0, ); - const filteredSlowestSegmentSpeedMph = + const filteredSlowestSegmentSpeedMps = routeSegments.length > 0 ? routeSegments.reduce( - (min, segment) => Math.min(min, segment.speedMph), + (min, segment) => Math.min(min, segment.speedMps), Number.POSITIVE_INFINITY, ) : null; @@ -453,12 +519,26 @@ export async function loader({ context }: Route.LoaderArgs) { (count, segment) => (segment.isStop ? count + 1 : count), 0, ); + const deviceSourcedSegmentCount = routeSegments.reduce( + (count, segment) => (segment.source === "device" ? count + 1 : count), + 0, + ); + const fallbackSegmentCount = routeSegments.length - deviceSourcedSegmentCount; + const usedFallbackForSomeReadings = + inputSpeedUnit != null && fallbackSegmentCount > 0; + + const chartSpeedCapMps = Math.min( + toMetersPerSecond(Number(summaryRow[0]?.chartSpeedCapMph ?? 0), "mph"), + toMetersPerSecond(outlierThresholdMph, "mph"), + ); const chartData = pointsWithDerivedSpeed.map((point) => ({ pointId: point.id, timestampMillis: toMillisTimestamp(point.timestamp), timestampLabel: displayDateTime(point.timestamp).toFormat("HH:mm"), - speedMph: Number((point.speedMps * MPS_TO_MPH).toFixed(2)), + speedDisplay: Number( + fromMetersPerSecond(point.speedMps, displaySpeedUnit).toFixed(2), + ), })); return { @@ -469,20 +549,34 @@ export async function loader({ context }: Route.LoaderArgs) { summary: { points: summaryRow[0]?.points ?? 0, segments: routeSegments.length, - averageSpeedMph: Number(filteredAverageSpeedMph.toFixed(1)), - maxSpeedMph: Number(filteredMaxSpeedMph.toFixed(1)), - chartSpeedCapMph: Number( - Math.min( - Number(summaryRow[0]?.chartSpeedCapMph ?? filteredMaxSpeedMph), - outlierThresholdMph, - ).toFixed(1), + averageSpeedDisplay: Number( + fromMetersPerSecond(filteredAverageSpeedMps, displaySpeedUnit).toFixed( + 1, + ), + ), + maxSpeedDisplay: Number( + fromMetersPerSecond(filteredMaxSpeedMps, displaySpeedUnit).toFixed(1), + ), + chartSpeedCapDisplay: Number( + fromMetersPerSecond(chartSpeedCapMps, displaySpeedUnit).toFixed(1), ), stopCount: filteredStopCount, - slowestSegmentSpeedMph: - filteredSlowestSegmentSpeedMph != null && - Number.isFinite(filteredSlowestSegmentSpeedMph) - ? Number(filteredSlowestSegmentSpeedMph.toFixed(1)) + slowestSegmentSpeedDisplay: + filteredSlowestSegmentSpeedMps != null && + Number.isFinite(filteredSlowestSegmentSpeedMps) + ? Number( + fromMetersPerSecond( + filteredSlowestSegmentSpeedMps, + displaySpeedUnit, + ).toFixed(1), + ) : null, + speedUnit: displaySpeedUnit, + speedUnitLabel, + hasDeviceSpeed: inputSpeedUnit != null, + deviceSourcedSegmentCount, + fallbackSegmentCount, + usedFallbackForSomeReadings, }, route: { points: pointsWithDerivedSpeed, @@ -495,23 +589,22 @@ export default function Page({ loaderData }: Route.ComponentProps) { const [hoveredPointId, setHoveredPointId] = useState(null); const liveMapHref = `/${loaderData.password}/${loaderData.urlDate}/live`; const segmentSpeeds = loaderData.route.segments.map( - (segment) => segment.speedMph, + (segment) => segment.speedDisplay, ); const hasSegmentSpeeds = segmentSpeeds.length > 0; const speedRange = getSpeedRange(segmentSpeeds); const legendTicks = hasSegmentSpeeds ? buildLegendTicks(speedRange, 5) : []; const chartYAxisMax = Math.max( 5, - loaderData.summary.chartSpeedCapMph, - loaderData.summary.maxSpeedMph * 1.05, - loaderData.summary.averageSpeedMph, + loaderData.summary.chartSpeedCapDisplay, + loaderData.summary.maxSpeedDisplay * 1.05, + loaderData.summary.averageSpeedDisplay, ); - const yAxisStepMph = getYAxisStepMph(chartYAxisMax); - const normalizedChartYAxisMax = - Math.ceil(chartYAxisMax / yAxisStepMph) * yAxisStepMph; + const yAxisStep = getYAxisStep(chartYAxisMax); + const normalizedChartYAxisMax = Math.ceil(chartYAxisMax / yAxisStep) * yAxisStep; const yAxisTicks = Array.from( - { length: normalizedChartYAxisMax / yAxisStepMph + 1 }, - (_, index) => index * yAxisStepMph, + { length: normalizedChartYAxisMax / yAxisStep + 1 }, + (_, index) => index * yAxisStep, ); return ( @@ -528,13 +621,19 @@ export default function Page({ loaderData }: Route.ComponentProps) { Average speed - {loaderData.summary.averageSpeedMph} mph + + {loaderData.summary.averageSpeedDisplay}{" "} + {loaderData.summary.speedUnitLabel} + Maximum speed - {loaderData.summary.maxSpeedMph} mph + + {loaderData.summary.maxSpeedDisplay}{" "} + {loaderData.summary.speedUnitLabel} + @@ -543,10 +642,22 @@ export default function Page({ loaderData }: Route.ComponentProps) {
Speed over time - Derived from the tracked position samples for the day. + {loaderData.summary.hasDeviceSpeed + ? "Uses this device's own reported speed where available, falling back to speed calculated from tracked position samples otherwise." + : "Derived from the tracked position samples for the day."}
+ {loaderData.summary.usedFallbackForSomeReadings ? ( + + {loaderData.summary.fallbackSegmentCount} of{" "} + {loaderData.summary.fallbackSegmentCount + + loaderData.summary.deviceSourcedSegmentCount}{" "} + readings for this day didn't include a reported speed from + the device, so calculated speed (derived from position samples, + which can be noisy) is shown for those instead. + + ) : null} {loaderData.chartData.length === 0 ? (
@@ -559,6 +670,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { ) : ( )} @@ -604,7 +717,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { <> {legendTicks.map((tick) => ( - +
- {tick.speedMph.toFixed(1)} mph + + {tick.speedValue.toFixed(1)}{" "} + {loaderData.summary.speedUnitLabel} + ))} - Min {speedRange.minMph.toFixed(1)} mph, max{" "} - {speedRange.maxMph.toFixed(1)} mph. + Min {speedRange.min.toFixed(1)}{" "} + {loaderData.summary.speedUnitLabel}, max{" "} + {speedRange.max.toFixed(1)} {loaderData.summary.speedUnitLabel} + . ) : ( diff --git a/website/app/utils/speedUnits.ts b/website/app/utils/speedUnits.ts new file mode 100644 index 0000000..410bd27 --- /dev/null +++ b/website/app/utils/speedUnits.ts @@ -0,0 +1,32 @@ +export const SPEED_UNITS = ["mps", "kmh", "kts", "mph"] as const; +export type SpeedUnit = (typeof SPEED_UNITS)[number]; + +// 1 knot = 1 nautical mile (1852 m) per hour; 1 mph = 1609.344 m per hour. +const METERS_PER_SECOND_PER_UNIT: Record = { + mps: 1, + kmh: 1 / 3.6, + kts: 1852 / 3600, + mph: 1609.344 / 3600, +}; + +export const SPEED_UNIT_LABELS: Record = { + mps: "m/s", + kmh: "km/h", + kts: "knots", + mph: "mph", +}; + +export const SPEED_UNIT_OPTIONS = SPEED_UNITS.map((unit) => ({ + value: unit, + label: SPEED_UNIT_LABELS[unit], +})); + +export const isSpeedUnit = (value: unknown): value is SpeedUnit => + typeof value === "string" && + (SPEED_UNITS as readonly string[]).includes(value); + +export const toMetersPerSecond = (value: number, unit: SpeedUnit) => + value * METERS_PER_SECOND_PER_UNIT[unit]; + +export const fromMetersPerSecond = (valueMps: number, unit: SpeedUnit) => + valueMps / METERS_PER_SECOND_PER_UNIT[unit]; diff --git a/website/database/migrations/0010_flashy_amphibian.sql b/website/database/migrations/0010_flashy_amphibian.sql new file mode 100644 index 0000000..624785f --- /dev/null +++ b/website/database/migrations/0010_flashy_amphibian.sql @@ -0,0 +1,2 @@ +ALTER TABLE `devices` ADD `input_speed_unit` text DEFAULT NULL;--> statement-breakpoint +ALTER TABLE `devices` ADD `display_speed_unit` text DEFAULT 'mph' NOT NULL; \ No newline at end of file diff --git a/website/database/migrations/meta/0010_snapshot.json b/website/database/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..deb6d77 --- /dev/null +++ b/website/database/migrations/meta/0010_snapshot.json @@ -0,0 +1,523 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cf604fc1-e658-47bc-b453-23daa13f83dd", + "prevId": "287b6c21-776f-4444-8f36-620bd9bedfda", + "tables": { + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date_string": { + "name": "date_string", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "h3_index": { + "name": "h3_index", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "h3_idx": { + "name": "h3_idx", + "columns": [ + "h3_index" + ], + "isUnique": false + }, + "device_dateString_h3_idx": { + "name": "device_dateString_h3_idx", + "columns": [ + "device_id", + "date_string", + "h3_index" + ], + "isUnique": false + }, + "device_dateString_timestamp_idx": { + "name": "device_dateString_timestamp_idx", + "columns": [ + "device_id", + "date_string", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "events_device_id_devices_id_fk": { + "name": "events_device_id_devices_id_fk", + "tableFrom": "events", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "timing_points": { + "name": "timing_points", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 99999 + }, + "latitude": { + "name": "latitude", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "longitude": { + "name": "longitude", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "h3_index": { + "name": "h3_index", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "radius": { + "name": "radius", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "google_link": { + "name": "google_link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Other Timing Points'" + } + }, + "indexes": { + "timing_points_device_idx": { + "name": "timing_points_device_idx", + "columns": [ + "device_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "timing_points_device_id_devices_id_fk": { + "name": "timing_points_device_id_devices_id_fk", + "tableFrom": "timing_points", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "timing_point_h3_cells": { + "name": "timing_point_h3_cells", + "columns": { + "timing_point_id": { + "name": "timing_point_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "h3_index": { + "name": "h3_index", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "timing_point_h3_cells_h3_idx": { + "name": "timing_point_h3_cells_h3_idx", + "columns": [ + "h3_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "timing_point_h3_cells_timing_point_id_timing_points_id_fk": { + "name": "timing_point_h3_cells_timing_point_id_timing_points_id_fk", + "tableFrom": "timing_point_h3_cells", + "tableTo": "timing_points", + "columnsFrom": [ + "timing_point_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "timing_point_h3_cells_timing_point_id_h3_index_pk": { + "columns": [ + "timing_point_id", + "h3_index" + ], + "name": "timing_point_h3_cells_timing_point_id_h3_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "access_passwords": { + "name": "access_passwords", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_dates": { + "name": "allowed_dates", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "access_passwords_password_unique": { + "name": "access_passwords_password_unique", + "columns": [ + "password" + ], + "isUnique": true + } + }, + "foreignKeys": { + "access_passwords_device_id_devices_id_fk": { + "name": "access_passwords_device_id_devices_id_fk", + "tableFrom": "access_passwords", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "devices": { + "name": "devices", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "match_id": { + "name": "match_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logbook_config": { + "name": "logbook_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "logbook_email_recipients": { + "name": "logbook_email_recipients", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "input_speed_unit": { + "name": "input_speed_unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "NULL" + }, + "display_speed_unit": { + "name": "display_speed_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mph'" + } + }, + "indexes": { + "devices_name_unique": { + "name": "devices_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "devices_match_id_unique": { + "name": "devices_match_id_unique", + "columns": [ + "match_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "logbook_remarks": { + "name": "logbook_remarks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date_string": { + "name": "date_string", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "logbook_remarks_device_dateString_idx": { + "name": "logbook_remarks_device_dateString_idx", + "columns": [ + "device_id", + "date_string" + ], + "isUnique": false + } + }, + "foreignKeys": { + "logbook_remarks_device_id_devices_id_fk": { + "name": "logbook_remarks_device_id_devices_id_fk", + "tableFrom": "logbook_remarks", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/website/database/migrations/meta/_journal.json b/website/database/migrations/meta/_journal.json index d207caa..0533b14 100644 --- a/website/database/migrations/meta/_journal.json +++ b/website/database/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1785144825704, "tag": "0009_thin_wolverine", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1785676095218, + "tag": "0010_flashy_amphibian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/website/database/schema/Devices.ts b/website/database/schema/Devices.ts index b627e32..beaaf9f 100644 --- a/website/database/schema/Devices.ts +++ b/website/database/schema/Devices.ts @@ -21,4 +21,14 @@ export const Devices = sqliteTable("devices", { logbookEmailRecipients: text("logbook_email_recipients", { mode: "text", }).default(sql`NULL`), + // Unit that this device's own reported `data.location.speed` is sent in (one of + // SpeedUnit from ~/utils/speedUnits). NULL means this device doesn't report a usable + // speed field, so speed is always calculated from consecutive position samples instead. + inputSpeedUnit: text("input_speed_unit", { mode: "text" }).default( + sql`NULL`, + ), + // Unit speeds are shown in throughout the app for this device (one of SpeedUnit). + displaySpeedUnit: text("display_speed_unit", { mode: "text" }) + .notNull() + .default("mph"), });