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
34 changes: 31 additions & 3 deletions website/app/components/DateIndexTable.module.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -24,4 +30,26 @@
box-shadow: var(--mantine-shadow-sm);
transform: scale(1.02);
}
}
}

.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);
}
}
1 change: 1 addition & 0 deletions website/app/components/DatePageNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type DatePage =
| "logbook"
| "timings"
| "analysis"
| "signal"
| "historic"
| "none";

Expand Down
68 changes: 68 additions & 0 deletions website/app/components/SignalMap/SignalMap.client.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ height: 420, width: "100%" }}>
<MapContainer
center={mapCenter}
zoom={13}
scrollWheelZoom={false}
touchZoom={true}
style={{ height: 420, width: "100%", zIndex: 0 }}
attributionControl={false}
>
<MapCenterConstraint bounds={viewportBounds} />
<MapZoomOutConstraint bounds={zoomOutBounds} />
<TileLayer
attribution='Map &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
{...config.tileLayer}
/>
{props.cells.map((cell) => (
<Polygon
key={cell.h3Index}
positions={cellToBoundary(cell.h3Index)}
pathOptions={{
color: cell.color,
fillColor: cell.color,
fillOpacity: 0.6,
weight: 1,
}}
>
<Popup>{cell.popup}</Popup>
</Polygon>
))}
</MapContainer>
</div>
);
}
11 changes: 11 additions & 0 deletions website/app/components/SignalMap/SignalMap.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ClientOnly fallback={<Center h={420} />}>
{() => <SignalMapClient {...props} />}
</ClientOnly>
);
}
32 changes: 32 additions & 0 deletions website/app/components/SignalMap/networkColor.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> => {
const colorByNetworkKey = new Map<string, string>();

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;
};
93 changes: 93 additions & 0 deletions website/app/components/SignalMap/signalColor.ts
Original file line number Diff line number Diff line change
@@ -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),
};
});
};
10 changes: 9 additions & 1 deletion website/app/components/mapPerformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type MapPerformanceConfig = {
};

export const mapPerformanceConfig: Record<
"analysis" | "live",
"analysis" | "live" | "signal",
MapPerformanceConfig
> = {
analysis: {
Expand All @@ -55,6 +55,14 @@ export const mapPerformanceConfig: Record<
updateInterval: 500,
},
},
signal: {
centerConstraintPaddingRatio: 0.9,
zoomOutPaddingRatio: 0.25,
tileLayer: {
...poorNetworkTileLayerDefaults,
updateInterval: 500,
},
},
};

export const createRestrictedViewportBounds = (
Expand Down
40 changes: 40 additions & 0 deletions website/app/constants/mccMncCarriers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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);
1 change: 1 addition & 0 deletions website/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]),
Expand Down
Loading