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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions website/app/components/AnalysisMap/AnalysisMap.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,7 +36,7 @@ export type AnalysisRouteSegment = {
timeDeltaSeconds: number;
distanceMeters: number;
speedMps: number;
speedMph: number;
speedDisplay: number;
isStop: boolean;
positions: [number, number][];
};
Expand All @@ -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 =
Expand Down Expand Up @@ -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,
}}
/>
Expand All @@ -119,7 +122,10 @@ export function AnalysisMap(props: {
<Popup>
{formatDateTimeMed(highlightedPoint.timestamp)}
<br />
{(highlightedPoint.speedMps * 2.2369362921).toFixed(1)} mph
{fromMetersPerSecond(highlightedPoint.speedMps, speedUnit).toFixed(
1,
)}{" "}
{SPEED_UNIT_LABELS[speedUnit]}
</Popup>
</Marker>
) : null}
Expand Down
2 changes: 2 additions & 0 deletions website/app/components/AnalysisMap/AnalysisMap.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +11,7 @@ export function AnalysisMap(props: {
points: AnalysisRoutePoint[];
segments: AnalysisRouteSegment[];
highlightedPointId?: number | null;
speedUnit?: SpeedUnit;
}) {
return (
<ClientOnly fallback={<Center h={420} />}>
Expand Down
40 changes: 20 additions & 20 deletions website/app/components/AnalysisMap/speedColor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 = (
Expand Down Expand Up @@ -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),
};
});
};
103 changes: 99 additions & 4 deletions website/app/routes/admin/devices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }];
};
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<number>`coalesce(${passwordCounts.passwordCount}, 0)`,
eventCount: sql<number>`coalesce(${eventCounts.eventCount}, 0)`,
})
Expand All @@ -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 };
}

Expand All @@ -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 };
}
Expand Down Expand Up @@ -211,8 +260,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<Container fluid p="md">
<Title order={1}>Device Administration</Title>
<Text c="dimmed" mb="md">
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.
</Text>

<Form method="post">
Expand All @@ -231,6 +280,24 @@ export default function Page({ loaderData }: Route.ComponentProps) {
allowDeselect={false}
required
/>
<Select
label="Input speed"
description="Unit of the speed this device itself reports"
name="inputSpeedUnit"
data={INPUT_SPEED_UNIT_OPTIONS}
defaultValue={NO_INPUT_SPEED_UNIT_VALUE}
allowDeselect={false}
required
/>
<Select
label="Display speed"
description="Unit speeds are shown in for this device"
name="displaySpeedUnit"
data={SPEED_UNIT_OPTIONS}
defaultValue={DEFAULT_DISPLAY_SPEED_UNIT}
allowDeselect={false}
required
/>
<Button type="submit">Create</Button>
</Group>
</Form>
Expand All @@ -241,6 +308,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<Table.Th>Name</Table.Th>
<Table.Th>Icon</Table.Th>
<Table.Th>Matcher</Table.Th>
<Table.Th>Input speed</Table.Th>
<Table.Th>Display speed</Table.Th>
<Table.Th>Passwords</Table.Th>
<Table.Th>Events</Table.Th>
<Table.Th>Actions</Table.Th>
Expand Down Expand Up @@ -287,6 +356,32 @@ export default function Page({ loaderData }: Route.ComponentProps) {
required
/>
</Table.Td>
<Table.Td>
<Select
aria-label="Input speed"
form={`device-row-${device.id}`}
name="inputSpeedUnit"
data={INPUT_SPEED_UNIT_OPTIONS}
defaultValue={
device.inputSpeedUnit ?? NO_INPUT_SPEED_UNIT_VALUE
}
allowDeselect={false}
required
/>
</Table.Td>
<Table.Td>
<Select
aria-label="Display speed"
form={`device-row-${device.id}`}
name="displaySpeedUnit"
data={SPEED_UNIT_OPTIONS}
defaultValue={
device.displaySpeedUnit ?? DEFAULT_DISPLAY_SPEED_UNIT
}
allowDeselect={false}
required
/>
</Table.Td>
<Table.Td>{device.passwordCount}</Table.Td>
<Table.Td>{device.eventCount}</Table.Td>
<Table.Td>
Expand Down
Loading