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"), });