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