From 9c0bf47743db9ecc2cf843f83b728f4ac5de9211 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:26:48 +0000 Subject: [PATCH 1/2] Add GSM signal strength and network coverage maps Adds a Signal map page showing two H3 hex-bin maps for the selected device/day: one coloured by average GSM signal strength (dBm), one by the dominant mobile network in use, both aggregated from the gsm.* fields already captured in each event's `other` JSON blob. --- website/app/components/DatePageNav.tsx | 2 + .../components/SignalMap/SignalMap.client.tsx | 68 +++++ .../app/components/SignalMap/SignalMap.tsx | 11 + .../app/components/SignalMap/networkColor.ts | 32 ++ .../app/components/SignalMap/signalColor.ts | 93 ++++++ website/app/components/mapPerformance.ts | 10 +- website/app/constants/mccMncCarriers.ts | 40 +++ website/app/routes.ts | 1 + website/app/routes/date/index.tsx | 16 + website/app/routes/date/protectedLayout.tsx | 16 +- website/app/routes/date/signal.tsx | 287 ++++++++++++++++++ 11 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 website/app/components/SignalMap/SignalMap.client.tsx create mode 100644 website/app/components/SignalMap/SignalMap.tsx create mode 100644 website/app/components/SignalMap/networkColor.ts create mode 100644 website/app/components/SignalMap/signalColor.ts create mode 100644 website/app/constants/mccMncCarriers.ts create mode 100644 website/app/routes/date/signal.tsx diff --git a/website/app/components/DatePageNav.tsx b/website/app/components/DatePageNav.tsx index 3100f4a..f5007b9 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"; @@ -26,6 +27,7 @@ const PAGES: Array<{ page: DatePage; label: string; path: string }> = [ { page: "logbook", label: "Logbook", path: "/logbook" }, { page: "timings", label: "Timing points", path: "/timings" }, { page: "analysis", label: "Analysis", path: "/analysis" }, + { page: "signal", label: "Signal map", path: "/signal" }, ]; export function DatePageNav({ password, urlDate, current }: DatePageNavProps) { 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..1ec2832 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, @@ -159,6 +160,21 @@ export default function Page({ loaderData }: Route.ComponentProps) { Historic comparison + + + + 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}) + + + ))} + + )} + + + + ); +} From 3a8535ed1dbe7d4957760c602e7750412c9c037b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:34:29 +0000 Subject: [PATCH 2/2] Move Signal map out of top nav, make Live tracking map more prominent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the tab bar and menu grid both growing, Signal map now only appears in the main menu, and Live tracking map — the primary action — gets its own larger button above the rest of the menu tiles. --- .../app/components/DateIndexTable.module.css | 34 +++++++++++++++++-- website/app/components/DatePageNav.tsx | 1 - website/app/routes/date/index.tsx | 26 ++++++++------ 3 files changed, 46 insertions(+), 15 deletions(-) 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 f5007b9..9a57394 100644 --- a/website/app/components/DatePageNav.tsx +++ b/website/app/components/DatePageNav.tsx @@ -27,7 +27,6 @@ const PAGES: Array<{ page: DatePage; label: string; path: string }> = [ { page: "logbook", label: "Logbook", path: "/logbook" }, { page: "timings", label: "Timing points", path: "/timings" }, { page: "analysis", label: "Analysis", path: "/analysis" }, - { page: "signal", label: "Signal map", path: "/signal" }, ]; export function DatePageNav({ password, urlDate, current }: DatePageNavProps) { diff --git a/website/app/routes/date/index.tsx b/website/app/routes/date/index.tsx index 1ec2832..85717a4 100644 --- a/website/app/routes/date/index.tsx +++ b/website/app/routes/date/index.tsx @@ -88,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 - +
+
+