diff --git a/website/app/components/DateIndexTable.module.css b/website/app/components/DateIndexTable.module.css
index e1966a5..5c71b02 100644
--- a/website/app/components/DateIndexTable.module.css
+++ b/website/app/components/DateIndexTable.module.css
@@ -1,5 +1,8 @@
.card {
- background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-7)
+ );
}
.title {
@@ -15,7 +18,10 @@
text-align: center;
border-radius: var(--mantine-radius-md);
height: 90px;
- background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
+ background-color: light-dark(
+ var(--mantine-color-white),
+ var(--mantine-color-dark-6)
+ );
transition:
box-shadow 150ms ease,
transform 100ms ease;
@@ -24,4 +30,26 @@
box-shadow: var(--mantine-shadow-sm);
transform: scale(1.02);
}
-}
\ No newline at end of file
+}
+
+.primaryItem {
+ display: flex;
+ align-items: center;
+ gap: var(--mantine-spacing-md);
+ border-radius: var(--mantine-radius-md);
+ padding: var(--mantine-spacing-lg);
+ background-color: light-dark(
+ var(--mantine-color-pink-0),
+ var(--mantine-color-dark-6)
+ );
+ border: 1px solid
+ light-dark(var(--mantine-color-pink-2), var(--mantine-color-pink-9));
+ transition:
+ box-shadow 150ms ease,
+ transform 100ms ease;
+
+ @mixin hover {
+ box-shadow: var(--mantine-shadow-sm);
+ transform: scale(1.01);
+ }
+}
diff --git a/website/app/components/DatePageNav.tsx b/website/app/components/DatePageNav.tsx
index 3100f4a..9a57394 100644
--- a/website/app/components/DatePageNav.tsx
+++ b/website/app/components/DatePageNav.tsx
@@ -11,6 +11,7 @@ export type DatePage =
| "logbook"
| "timings"
| "analysis"
+ | "signal"
| "historic"
| "none";
diff --git a/website/app/components/SignalMap/SignalMap.client.tsx b/website/app/components/SignalMap/SignalMap.client.tsx
new file mode 100644
index 0000000..88cefd3
--- /dev/null
+++ b/website/app/components/SignalMap/SignalMap.client.tsx
@@ -0,0 +1,68 @@
+import "leaflet/dist/leaflet.css";
+import { cellToBoundary } from "h3-js";
+import type { ReactNode } from "react";
+import { MapContainer, Polygon, Popup, TileLayer } from "react-leaflet";
+import {
+ createRestrictedViewportBounds,
+ mapPerformanceConfig,
+} from "../mapPerformance";
+import { MapCenterConstraint } from "../MapCenterConstraint.client";
+import { MapZoomOutConstraint } from "../MapZoomOutConstraint.client";
+
+export type HexCell = {
+ h3Index: string;
+ latitude: number;
+ longitude: number;
+ color: string;
+ popup: ReactNode;
+};
+
+export function SignalMap(props: { cells: HexCell[] }) {
+ const config = mapPerformanceConfig.signal;
+
+ const mapCenter = props.cells[0]
+ ? ([props.cells[0].latitude, props.cells[0].longitude] as [number, number])
+ : ([0, 0] as [number, number]);
+
+ const viewportBounds = createRestrictedViewportBounds(props.cells, {
+ paddingRatio: config.centerConstraintPaddingRatio,
+ });
+ const zoomOutBounds = createRestrictedViewportBounds(props.cells, {
+ paddingRatio: config.zoomOutPaddingRatio,
+ });
+
+ return (
+
+
+
+
+
+ {props.cells.map((cell) => (
+
+ {cell.popup}
+
+ ))}
+
+
+ );
+}
diff --git a/website/app/components/SignalMap/SignalMap.tsx b/website/app/components/SignalMap/SignalMap.tsx
new file mode 100644
index 0000000..1b4f91b
--- /dev/null
+++ b/website/app/components/SignalMap/SignalMap.tsx
@@ -0,0 +1,11 @@
+import { Center } from "@mantine/core";
+import { ClientOnly } from "remix-utils/client-only";
+import { SignalMap as SignalMapClient, type HexCell } from "./SignalMap.client";
+
+export function SignalMap(props: { cells: HexCell[] }) {
+ return (
+ }>
+ {() => }
+
+ );
+}
diff --git a/website/app/components/SignalMap/networkColor.ts b/website/app/components/SignalMap/networkColor.ts
new file mode 100644
index 0000000..38ba8d7
--- /dev/null
+++ b/website/app/components/SignalMap/networkColor.ts
@@ -0,0 +1,32 @@
+/**
+ * Categorical colours for distinct mobile networks. Unlike signalColor.ts's gradient, each
+ * network gets a fixed, visually distinct colour rather than one interpolated from a range.
+ */
+const NETWORK_PALETTE = [
+ "#1971c2", // blue
+ "#e8590c", // orange
+ "#2f9e44", // green
+ "#e64980", // pink
+ "#7048e8", // violet
+ "#f08c00", // amber
+ "#0c8599", // cyan
+ "#c2255c", // grape
+ "#5c940d", // lime
+ "#495057", // gray
+];
+
+/** Assigns each distinct network key a stable colour, in first-seen order. */
+export const assignNetworkColors = (
+ networkKeysInOrder: string[],
+): Map => {
+ const colorByNetworkKey = new Map();
+
+ for (const key of networkKeysInOrder) {
+ if (colorByNetworkKey.has(key)) continue;
+ const color =
+ NETWORK_PALETTE[colorByNetworkKey.size % NETWORK_PALETTE.length];
+ colorByNetworkKey.set(key, color);
+ }
+
+ return colorByNetworkKey;
+};
diff --git a/website/app/components/SignalMap/signalColor.ts b/website/app/components/SignalMap/signalColor.ts
new file mode 100644
index 0000000..d260e8f
--- /dev/null
+++ b/website/app/components/SignalMap/signalColor.ts
@@ -0,0 +1,93 @@
+type RgbColor = [number, number, number];
+
+const PALETTE_STOPS: Array<{ t: number; color: RgbColor }> = [
+ { t: 0, color: [220, 38, 38] },
+ { t: 0.5, color: [245, 158, 11] },
+ { t: 1, color: [22, 163, 74] },
+];
+
+export type SignalRange = {
+ minDbm: number;
+ maxDbm: number;
+};
+
+const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
+
+const formatHexChannel = (value: number) =>
+ Math.round(value).toString(16).padStart(2, "0");
+
+const rgbToHex = ([red, green, blue]: RgbColor) =>
+ `#${formatHexChannel(red)}${formatHexChannel(green)}${formatHexChannel(blue)}`;
+
+export const getSignalRange = (dbmValues: number[]): SignalRange => {
+ const validValues = dbmValues.filter((dbm) => Number.isFinite(dbm));
+
+ if (validValues.length === 0) {
+ return { minDbm: -110, maxDbm: -50 };
+ }
+
+ const minDbm = Math.min(...validValues);
+ const maxDbm = Math.max(...validValues);
+
+ if (Math.abs(maxDbm - minDbm) < 1e-9) {
+ return { minDbm: minDbm - 1, maxDbm };
+ }
+
+ return { minDbm, maxDbm };
+};
+
+const interpolateRgb = (
+ start: RgbColor,
+ end: RgbColor,
+ t: number,
+): RgbColor => [
+ start[0] + (end[0] - start[0]) * t,
+ start[1] + (end[1] - start[1]) * t,
+ start[2] + (end[2] - start[2]) * t,
+];
+
+const getPaletteColorAt = (normalizedValue: number) => {
+ const t = clamp01(normalizedValue);
+
+ for (let i = 1; i < PALETTE_STOPS.length; i += 1) {
+ const left = PALETTE_STOPS[i - 1];
+ const right = PALETTE_STOPS[i];
+
+ if (t <= right.t) {
+ const segmentSpan = right.t - left.t || 1;
+ const localT = (t - left.t) / segmentSpan;
+ return rgbToHex(interpolateRgb(left.color, right.color, localT));
+ }
+ }
+
+ return rgbToHex(PALETTE_STOPS[PALETTE_STOPS.length - 1].color);
+};
+
+/** Weaker (more negative) dBm readings are red, stronger readings are green. */
+export const signalToColor = (dbm: number, signalRange: SignalRange) => {
+ const range = signalRange.maxDbm - signalRange.minDbm;
+ const normalized = range > 0 ? (dbm - signalRange.minDbm) / range : 0;
+ return getPaletteColorAt(normalized);
+};
+
+export const buildSignalLegendTicks = (
+ signalRange: SignalRange,
+ tickCount = 5,
+) => {
+ if (tickCount < 2) {
+ const dbm = signalRange.minDbm;
+ return [{ dbm, color: signalToColor(dbm, signalRange) }];
+ }
+
+ const span = signalRange.maxDbm - signalRange.minDbm;
+
+ return Array.from({ length: tickCount }, (_, index) => {
+ const ratio = index / (tickCount - 1);
+ const dbm = signalRange.minDbm + span * ratio;
+
+ return {
+ dbm,
+ color: signalToColor(dbm, signalRange),
+ };
+ });
+};
diff --git a/website/app/components/mapPerformance.ts b/website/app/components/mapPerformance.ts
index 65989a3..f4d23e3 100644
--- a/website/app/components/mapPerformance.ts
+++ b/website/app/components/mapPerformance.ts
@@ -36,7 +36,7 @@ type MapPerformanceConfig = {
};
export const mapPerformanceConfig: Record<
- "analysis" | "live",
+ "analysis" | "live" | "signal",
MapPerformanceConfig
> = {
analysis: {
@@ -55,6 +55,14 @@ export const mapPerformanceConfig: Record<
updateInterval: 500,
},
},
+ signal: {
+ centerConstraintPaddingRatio: 0.9,
+ zoomOutPaddingRatio: 0.25,
+ tileLayer: {
+ ...poorNetworkTileLayerDefaults,
+ updateInterval: 500,
+ },
+ },
};
export const createRestrictedViewportBounds = (
diff --git a/website/app/constants/mccMncCarriers.ts b/website/app/constants/mccMncCarriers.ts
new file mode 100644
index 0000000..753a2aa
--- /dev/null
+++ b/website/app/constants/mccMncCarriers.ts
@@ -0,0 +1,40 @@
+/**
+ * Friendly names for the UK mobile networks (MCC 234/235) most likely to show up in tracker
+ * data. Not an exhaustive world MCC-MNC database — just enough to label the common carriers;
+ * anything else falls back to the raw "MCC-MNC" code.
+ */
+const CARRIER_NAMES: Record = {
+ "234-00": "BT",
+ "234-02": "O2",
+ "234-10": "O2",
+ "234-11": "O2",
+ "234-15": "Vodafone",
+ "234-16": "TalkTalk",
+ "234-20": "Three",
+ "234-26": "Lycamobile",
+ "234-30": "EE",
+ "234-31": "EE",
+ "234-32": "EE",
+ "234-33": "EE",
+ "234-34": "EE",
+ "234-38": "Virgin Mobile",
+ "234-54": "iD Mobile",
+ "234-57": "Sky Mobile",
+ "234-76": "BT",
+ "234-77": "Vodafone",
+ "234-86": "EE",
+ "234-87": "Lebara",
+ "235-01": "EE",
+ "235-02": "EE",
+ "235-77": "BT",
+ "235-91": "Vodafone",
+ "235-94": "Three",
+};
+
+const normalizeMnc = (mnc: string | number) => String(mnc).padStart(2, "0");
+
+export const formatMccMnc = (mcc: string | number, mnc: string | number) =>
+ `${mcc}-${normalizeMnc(mnc)}`;
+
+export const getCarrierName = (mcc: string | number, mnc: string | number) =>
+ CARRIER_NAMES[formatMccMnc(mcc, mnc)] ?? formatMccMnc(mcc, mnc);
diff --git a/website/app/routes.ts b/website/app/routes.ts
index 40c81d6..9dcd727 100644
--- a/website/app/routes.ts
+++ b/website/app/routes.ts
@@ -25,6 +25,7 @@ export default [
"./routes/date/timingPointsHistoricComparison.tsx",
),
route("analysis", "./routes/date/analysis.tsx"),
+ route("signal", "./routes/date/signal.tsx"),
route("export.gpx", "./routes/date/downloadGPX.ts"),
index("./routes/date/index.tsx"),
]),
diff --git a/website/app/routes/date/index.tsx b/website/app/routes/date/index.tsx
index f9de6b8..85717a4 100644
--- a/website/app/routes/date/index.tsx
+++ b/website/app/routes/date/index.tsx
@@ -18,6 +18,7 @@ import { getDb, getPasswordRouteAccess } from "~/routeContext";
import { formatUtcDay } from "~/utils/dateTime";
import type { Route } from "./+types/index";
import {
+ IconAntennaBars5,
IconDeviceAnalytics,
IconDownload,
IconGitCompare,
@@ -87,18 +88,22 @@ export default function Page({ loaderData }: Route.ComponentProps) {
Change date
-
-
-
-
- Live tracking map
+
+
+
+ Live tracking map
+
+ See where the device is right now
-
+
+
+
+
+
+
+ Signal map
+
+
diff --git a/website/app/routes/date/signal.tsx b/website/app/routes/date/signal.tsx
new file mode 100644
index 0000000..1a0cb04
--- /dev/null
+++ b/website/app/routes/date/signal.tsx
@@ -0,0 +1,287 @@
+import { getDb, getPasswordRouteAccess } from "~/routeContext";
+import {
+ Card,
+ Center,
+ Container,
+ Group,
+ SimpleGrid,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
+import { and, eq, sql } from "drizzle-orm";
+import type { MetaFunction } from "react-router";
+import { SignalMap } from "~/components/SignalMap/SignalMap";
+import type { HexCell } from "~/components/SignalMap/SignalMap.client";
+import {
+ buildSignalLegendTicks,
+ getSignalRange,
+ signalToColor,
+} from "~/components/SignalMap/signalColor";
+import { assignNetworkColors } from "~/components/SignalMap/networkColor";
+import { formatMccMnc, getCarrierName } from "~/constants/mccMncCarriers";
+import * as Schema from "~/database/schema.d";
+import type { Route } from "./+types/signal";
+
+export const meta: MetaFunction = () => {
+ return [{ title: "Signal map" }];
+};
+
+const SIGNAL_DBM_PATH = '$."other"."gsm.signal.dbm"';
+const MCC_PATH = '$."other"."gsm.mcc"';
+const MNC_PATH = '$."other"."gsm.mnc"';
+
+export async function loader({ context }: Route.LoaderArgs) {
+ const { urlDate, password, deviceId } = getPasswordRouteAccess(context);
+ const db = getDb(context);
+
+ const signalValue = sql`json_extract(${Schema.Events.data}, ${SIGNAL_DBM_PATH})`;
+
+ const signalRows = await db
+ .select({
+ h3Index: Schema.Events.h3Index,
+ latitude: sql`AVG(${Schema.Events.latitude})`.as("latitude"),
+ longitude: sql`AVG(${Schema.Events.longitude})`.as("longitude"),
+ avgDbm: sql`AVG(${signalValue})`.as("avg_dbm"),
+ readingCount: sql`COUNT(*)`.as("reading_count"),
+ })
+ .from(Schema.Events)
+ .where(
+ and(
+ eq(Schema.Events.deviceId, deviceId),
+ eq(Schema.Events.dateString, urlDate),
+ sql`${signalValue} IS NOT NULL`,
+ ),
+ )
+ .groupBy(Schema.Events.h3Index);
+
+ const mccValue = sql`json_extract(${Schema.Events.data}, ${MCC_PATH})`;
+ const mncValue = sql`json_extract(${Schema.Events.data}, ${MNC_PATH})`;
+
+ const networkCounts = db.$with("network_counts").as(
+ db
+ .select({
+ h3Index: Schema.Events.h3Index,
+ mcc: mccValue.as("mcc"),
+ mnc: mncValue.as("mnc"),
+ latitude: sql`AVG(${Schema.Events.latitude})`.as("latitude"),
+ longitude: sql`AVG(${Schema.Events.longitude})`.as("longitude"),
+ count: sql`COUNT(*)`.as("count"),
+ })
+ .from(Schema.Events)
+ .where(
+ and(
+ eq(Schema.Events.deviceId, deviceId),
+ eq(Schema.Events.dateString, urlDate),
+ sql`${mccValue} IS NOT NULL`,
+ sql`${mncValue} IS NOT NULL`,
+ ),
+ )
+ .groupBy(Schema.Events.h3Index, sql`mcc`, sql`mnc`),
+ );
+
+ const rankedNetworkCounts = db.$with("ranked_network_counts").as(
+ db
+ .select({
+ h3Index: networkCounts.h3Index,
+ mcc: networkCounts.mcc,
+ mnc: networkCounts.mnc,
+ latitude: networkCounts.latitude,
+ longitude: networkCounts.longitude,
+ count: networkCounts.count,
+ rank: sql`ROW_NUMBER() OVER (PARTITION BY ${networkCounts.h3Index} ORDER BY ${networkCounts.count} DESC)`.as(
+ "rank",
+ ),
+ })
+ .from(networkCounts),
+ );
+
+ const networkRows = await db
+ .with(networkCounts, rankedNetworkCounts)
+ .select({
+ h3Index: rankedNetworkCounts.h3Index,
+ mcc: rankedNetworkCounts.mcc,
+ mnc: rankedNetworkCounts.mnc,
+ latitude: rankedNetworkCounts.latitude,
+ longitude: rankedNetworkCounts.longitude,
+ readingCount: rankedNetworkCounts.count,
+ })
+ .from(rankedNetworkCounts)
+ .where(eq(rankedNetworkCounts.rank, 1));
+
+ return {
+ urlDate,
+ password,
+ signalCells: signalRows.map((row) => ({
+ h3Index: row.h3Index,
+ latitude: Number(row.latitude),
+ longitude: Number(row.longitude),
+ avgDbm: Number(row.avgDbm),
+ readingCount: Number(row.readingCount),
+ })),
+ networkCells: networkRows.map((row) => ({
+ h3Index: row.h3Index,
+ latitude: Number(row.latitude),
+ longitude: Number(row.longitude),
+ mcc: String(row.mcc),
+ mnc: String(row.mnc),
+ readingCount: Number(row.readingCount),
+ })),
+ };
+}
+
+export default function Page({ loaderData }: Route.ComponentProps) {
+ const hasSignalData = loaderData.signalCells.length > 0;
+ const hasNetworkData = loaderData.networkCells.length > 0;
+
+ const signalRange = getSignalRange(
+ loaderData.signalCells.map((cell) => cell.avgDbm),
+ );
+ const signalLegendTicks = hasSignalData
+ ? buildSignalLegendTicks(signalRange, 5)
+ : [];
+ const signalHexCells: HexCell[] = loaderData.signalCells.map((cell) => ({
+ h3Index: cell.h3Index,
+ latitude: cell.latitude,
+ longitude: cell.longitude,
+ color: signalToColor(cell.avgDbm, signalRange),
+ popup: (
+ <>
+ {cell.avgDbm.toFixed(0)} dBm
+
+ {cell.readingCount} reading{cell.readingCount === 1 ? "" : "s"}
+ >
+ ),
+ }));
+
+ const networkTotals = new Map<
+ string,
+ { mcc: string; mnc: string; total: number }
+ >();
+ for (const cell of loaderData.networkCells) {
+ const key = formatMccMnc(cell.mcc, cell.mnc);
+ const existing = networkTotals.get(key);
+ if (existing) {
+ existing.total += cell.readingCount;
+ } else {
+ networkTotals.set(key, {
+ mcc: cell.mcc,
+ mnc: cell.mnc,
+ total: cell.readingCount,
+ });
+ }
+ }
+ const sortedNetworkEntries = [...networkTotals.entries()].sort(
+ (a, b) => b[1].total - a[1].total,
+ );
+ const networkColorByKey = assignNetworkColors(
+ sortedNetworkEntries.map(([key]) => key),
+ );
+ const networkHexCells: HexCell[] = loaderData.networkCells.map((cell) => {
+ const key = formatMccMnc(cell.mcc, cell.mnc);
+ const color = networkColorByKey.get(key) ?? "#495057";
+ return {
+ h3Index: cell.h3Index,
+ latitude: cell.latitude,
+ longitude: cell.longitude,
+ color,
+ popup: (
+ <>
+ {getCarrierName(cell.mcc, cell.mnc)}
+
+ {cell.readingCount} reading{cell.readingCount === 1 ? "" : "s"}
+ >
+ ),
+ };
+ });
+
+ return (
+
+
+
+
+
+
Signal strength heatmap
+
+ Each cell shows the average GSM signal strength for the day: red
+ is weak, green is strong.
+
+
+
+ {hasSignalData ? (
+
+ ) : (
+
+
+ No GSM signal data recorded for this day yet
+
+
+ )}
+ {hasSignalData && (
+
+
+ {signalLegendTicks.map((tick) => (
+
+
+ {tick.dbm.toFixed(0)} dBm
+
+ ))}
+
+
+ Min {signalRange.minDbm.toFixed(0)} dBm, max{" "}
+ {signalRange.maxDbm.toFixed(0)} dBm.
+
+
+ )}
+
+
+
+
+
+
Mobile network coverage
+
+ Each cell is coloured by the mobile network most often in use
+ there for the day.
+
+
+
+ {hasNetworkData ? (
+
+ ) : (
+
+ No network data recorded for this day yet
+
+ )}
+ {hasNetworkData && (
+
+ {sortedNetworkEntries.map(([key, entry]) => (
+
+
+
+ {getCarrierName(entry.mcc, entry.mnc)} ({entry.total})
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}