From 36443fd838a4e4a92ef1e92ba262368ff1171aea Mon Sep 17 00:00:00 2001 From: Jeremy Gallant <8975765+philon-@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:13:34 +0200 Subject: [PATCH] Map improvements - node and neighbor display (#850) * Improves map node and neighbor display Updates the map page to enhance the visualization of nodes, waypoints, and neighbor connections. Adds a map layer tool for toggling the visibility of direct/remote neighbors and position precision indicators. Introduces clustering to improve map readability when multiple nodes share the exact same location. Adds SNR lines to visually represent network connections between nodes Co-Authored-By: jamon * Clean up * Update packages/web/src/components/generic/TimeAgo.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Improve dark mode and expires field * Review fixes Co-Authored-By: Dan Ditomaso --------- Co-authored-by: philon- Co-authored-by: jamon Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Dan Ditomaso --- packages/web/package.json | 1 + .../web/public/i18n/locales/en/common.json | 4 +- packages/web/public/i18n/locales/en/map.json | 38 ++ packages/web/public/i18n/locales/en/ui.json | 6 +- packages/web/src/components/Map.tsx | 42 ++- .../PageComponents/Map/Layers/NodesLayer.tsx | 174 +++++++++ .../Map/Layers/PrecisionLayer.tsx | 137 +++++++ .../PageComponents/Map/Layers/SNRLayer.tsx | 341 +++++++++++++++++ .../Map/Layers/WaypointLayer.tsx | 94 +++++ .../PageComponents/Map/Markers/NodeMarker.tsx | 106 ++++++ .../PageComponents/Map/Markers/StackBadge.tsx | 33 ++ .../Map/{ => Popups}/NodeDetail.tsx | 12 +- .../Map/Popups/PopupWrapper.tsx | 38 ++ .../Map/Popups/WaypointDetail.tsx | 191 ++++++++++ .../PageComponents/Map/Tools/MapLayerTool.tsx | 143 +++++++ .../components/PageComponents/Map/cluster.ts | 56 +++ packages/web/src/components/UI/Avatar.tsx | 13 +- packages/web/src/components/UI/Tooltip.tsx | 3 + .../web/src/components/generic/TimeAgo.tsx | 2 +- packages/web/src/core/hooks/useMapFitting.ts | 50 +++ .../web/src/core/stores/deviceStore/index.ts | 65 +++- packages/web/src/core/stores/index.ts | 1 + .../web/src/core/stores/nodeDBStore/index.ts | 13 +- .../stores/nodeDBStore/nodeDBStore.test.tsx | 6 +- packages/web/src/core/subscriptions.ts | 8 +- packages/web/src/core/utils/geo.ts | 87 +++++ packages/web/src/core/utils/signalColor.ts | 31 ++ packages/web/src/i18n-config.ts | 1 + packages/web/src/index.css | 6 + packages/web/src/pages/Map/index.tsx | 354 ++++++++++-------- pnpm-lock.yaml | 3 + 31 files changed, 1877 insertions(+), 182 deletions(-) create mode 100644 packages/web/public/i18n/locales/en/map.json create mode 100644 packages/web/src/components/PageComponents/Map/Layers/NodesLayer.tsx create mode 100644 packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx create mode 100644 packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx create mode 100644 packages/web/src/components/PageComponents/Map/Layers/WaypointLayer.tsx create mode 100644 packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx create mode 100644 packages/web/src/components/PageComponents/Map/Markers/StackBadge.tsx rename packages/web/src/components/PageComponents/Map/{ => Popups}/NodeDetail.tsx (94%) create mode 100644 packages/web/src/components/PageComponents/Map/Popups/PopupWrapper.tsx create mode 100644 packages/web/src/components/PageComponents/Map/Popups/WaypointDetail.tsx create mode 100644 packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx create mode 100644 packages/web/src/components/PageComponents/Map/cluster.ts create mode 100644 packages/web/src/core/hooks/useMapFitting.ts create mode 100644 packages/web/src/core/utils/geo.ts create mode 100644 packages/web/src/core/utils/signalColor.ts diff --git a/packages/web/package.json b/packages/web/package.json index 43f6fd66..7d082b11 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -92,6 +92,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.1.0", + "@types/geojson": "^7946.0.16", "@types/js-cookie": "^3.0.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/packages/web/public/i18n/locales/en/common.json b/packages/web/public/i18n/locales/en/common.json index 1eed0e5a..6c25fc9f 100644 --- a/packages/web/public/i18n/locales/en/common.json +++ b/packages/web/public/i18n/locales/en/common.json @@ -47,6 +47,7 @@ "megahertz": "MHz", "raw": "raw", "meter": { "one": "Meter", "plural": "Meters", "suffix": "m" }, + "kilometer": { "one": "Kilometer", "plural": "Kilometers", "suffix": "km" }, "minute": { "one": "Minute", "plural": "Minutes" }, "hour": { "one": "Hour", "plural": "Hours" }, "millisecond": { @@ -65,7 +66,8 @@ "year": { "one": "Year", "plural": "Years" }, "snr": "SNR", "volt": { "one": "Volt", "plural": "Volts", "suffix": "V" }, - "record": { "one": "Records", "plural": "Records" } + "record": { "one": "Records", "plural": "Records" }, + "degree": { "one": "Degree", "plural": "Degrees", "suffix": "°" } }, "security": { "0bit": "Empty", diff --git a/packages/web/public/i18n/locales/en/map.json b/packages/web/public/i18n/locales/en/map.json new file mode 100644 index 00000000..c17f38b2 --- /dev/null +++ b/packages/web/public/i18n/locales/en/map.json @@ -0,0 +1,38 @@ +{ + "maplibre": { + "GeolocateControl.FindMyLocation": "Find my location", + "NavigationControl.ZoomIn": "Zoom in", + "NavigationControl.ZoomOut": "Zoom out", + "CooperativeGesturesHandler.WindowsHelpText": "Use Ctrl + scroll to zoom the map", + "CooperativeGesturesHandler.MacHelpText": "Use ⌘ + scroll to zoom the map", + "CooperativeGesturesHandler.MobileHelpText": "Use two fingers to move the map" + }, + "layerTool": { + "nodeMarkers": "Show nodes", + "directNeighbors": "Show direct connections", + "remoteNeighbors": "Show remote connections", + "positionPrecision": "Show position precision", + "traceroutes": "Show traceroutes", + "waypoints": "Show waypoints" + }, + "mapMenu": { + "locateAria": "Locate my node", + "layersAria": "Change map style" + }, + "waypointDetail": { + "edit": "Edit", + "description": "Description:", + "createdBy": "Edited by:", + "createdDate": "Created:", + "updated": "Updated:", + "expires": "Expires:", + "distance": "Distance:", + "bearing": "Absolute bearing:", + "lockedTo": "Locked by:", + "latitude": "Latitude:", + "longitude": "Longitude:" + }, + "myNode": { + "tooltip": "This device" + } +} diff --git a/packages/web/public/i18n/locales/en/ui.json b/packages/web/public/i18n/locales/en/ui.json index 205da2d1..694e8982 100644 --- a/packages/web/public/i18n/locales/en/ui.json +++ b/packages/web/public/i18n/locales/en/ui.json @@ -140,7 +140,8 @@ "placeholder": "Meshtastic 1234" }, "airtimeUtilization": { - "label": "Airtime Utilization (%)" + "label": "Airtime Utilization (%)", + "short": "Airtime Util. (%)" }, "batteryLevel": { "label": "Battery level (%)", @@ -151,7 +152,8 @@ "title": "Voltage" }, "channelUtilization": { - "label": "Channel Utilization (%)" + "label": "Channel Utilization (%)", + "short": "Channel Util. (%)" }, "hops": { "direct": "Direct", diff --git a/packages/web/src/components/Map.tsx b/packages/web/src/components/Map.tsx index 483538f6..c0ced881 100644 --- a/packages/web/src/components/Map.tsx +++ b/packages/web/src/components/Map.tsx @@ -1,7 +1,9 @@ import { useTheme } from "@core/hooks/useTheme.ts"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import MapGl, { AttributionControl, + type MapLayerMouseEvent, type MapRef, NavigationControl, ScaleControl, @@ -10,10 +12,21 @@ import MapGl, { interface MapProps { children?: React.ReactNode; onLoad?: (map: MapRef) => void; + onMouseMove?: (event: MapLayerMouseEvent) => void; + onClick?: (event: MapLayerMouseEvent) => void; + interactiveLayerIds?: string[]; } -export const BaseMap = ({ children, onLoad }: MapProps) => { +export const BaseMap = ({ + children, + onLoad, + onClick, + onMouseMove, + interactiveLayerIds, +}: MapProps) => { const { theme } = useTheme(); + const { t } = useTranslation("map"); + const darkMode = theme === "dark"; const mapRef = useRef(null); @@ -24,6 +37,27 @@ export const BaseMap = ({ children, onLoad }: MapProps) => { } }, [onLoad]); + const locale = useMemo(() => { + return { + "GeolocateControl.FindMyLocation": t( + "maplibre.GeolocateControl.FindMyLocation", + ), + "NavigationControl.ZoomIn": t("maplibre.NavigationControl.ZoomIn"), + "NavigationControl.ZoomOut": t("maplibre.NavigationControl.ZoomOut"), + "ScaleControl.Meters": t("unit.meter.suffix"), + "ScaleControl.Kilometers": t("unit.kilometer.suffix"), + "CooperativeGesturesHandler.WindowsHelpText": t( + "maplibre.CooperativeGesturesHandler.WindowsHelpText", + ), + "CooperativeGesturesHandler.MacHelpText": t( + "maplibre.CooperativeGesturesHandler.MacHelpText", + ), + "CooperativeGesturesHandler.MobileHelpText": t( + "maplibre.CooperativeGesturesHandler.MobileHelpText", + ), + }; + }, [t]); + return ( { longitude: 0, }} style={{ filter: darkMode ? "brightness(0.9)" : undefined }} + locale={locale} + interactiveLayerIds={interactiveLayerIds} + onMouseMove={onMouseMove} + onClick={onClick} > void; + popupState: PopupState | undefined; + setPopupState: (state: PopupState | undefined) => void; + isVisible: boolean; +} + +export const NodesLayer = ({ + mapRef, + filteredNodes, + myNode, + expandedCluster, + setExpandedCluster, + popupState, + setPopupState, + isVisible, +}: NodeMarkerProps): React.ReactNode[] => { + const { t } = useTranslation("map"); + + const { hasNodeError } = useNodeDB(); + const { focusLngLat } = useMapFitting(mapRef); + + const selectedNode = useMemo( + () => + popupState?.type !== "node" + ? undefined + : (filteredNodes.find((node) => node.num === popupState.num) ?? + undefined), + [popupState, filteredNodes], + ); + + const onMarkerClick = useCallback( + (num: number, offset: PxOffset, e: { originalEvent: MouseEvent }) => { + e.originalEvent?.stopPropagation(); + setPopupState({ type: "node", num, offset }); + const node = filteredNodes.find((node) => node.num === num) ?? undefined; + if (node) { + focusLngLat(toLngLat(node.position)); + } + }, + [filteredNodes, focusLngLat, setPopupState], + ); + + const clusters = groupNodesByIdenticalCoords(filteredNodes); + const rendered: React.ReactNode[] = []; + + for (const [key, nodes] of clusters) { + if (!nodes.length || !nodes[0]?.position) { + continue; + } + const [lng, lat] = toLngLat(nodes[0].position); + const isExpanded = expandedCluster === key; + + // Precompute pixel offsets for expanded state + const expandedOffsets = isExpanded + ? fanOutOffsetsPx(nodes.length, key) + : undefined; + + // Always render all node markers in the cluster + for (const [i, node] of nodes.entries()) { + const isHead = i === 0; + + rendered.push( + { + e.originalEvent?.stopPropagation(); + if (!isExpanded && !isHead) { + // collapsed: tapping a buried marker expands the stack first + setExpandedCluster(key); + return; + } + onMarkerClick(num, expandedOffsets?.[i] ?? [0, 0], e); + }} + />, + ); + } + + if (nodes.length > 1) { + rendered.push( + { + e.originalEvent?.stopPropagation(); + setExpandedCluster(key); + }} + />, + ); + } + } + + if (selectedNode) { + rendered.push( + , + ); + + const [lng, lat] = toLngLat(selectedNode.position); + + rendered.push( + setPopupState(undefined)} + > + + , + ); + } + + if (myNode && hasPos(myNode.position)) { + const [lng, lat] = toLngLat(myNode.position); + rendered.push( + onMarkerClick(myNode.num, [0, 0], e)} + />, + ); + } + + return rendered; +}; diff --git a/packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx new file mode 100644 index 00000000..dd0ebca1 --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx @@ -0,0 +1,137 @@ +import { getColorFromText, isLightColor } from "@app/core/utils/color"; +import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts"; +import type { Protobuf } from "@meshtastic/core"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import { circle } from "@turf/turf"; +import type { Feature, FeatureCollection, Polygon } from "geojson"; +import { Layer, Source } from "react-map-gl/maplibre"; + +export interface PrecisionLayerProps { + id: string; + filteredNodes: Protobuf.Mesh.NodeInfo[]; + isVisible: boolean; +} + +type CircleProps = { + r: number; + g: number; + b: number; + a?: number; + sortKey: number; +}; + +export function generatePrecisionCircles( + filteredNodes: Protobuf.Mesh.NodeInfo[], +): FeatureCollection { + const unique = new Map< + string, + { + lng: number; + lat: number; + radiusM: number; + r: number; + g: number; + b: number; + a: number; + } + >(); + + for (const node of filteredNodes) { + if ( + node.position?.precisionBits === undefined || + node.position.precisionBits === 0 + ) { + continue; + } + const [lng, lat] = toLngLat(node.position); + const radiusM = precisionBitsToMeters(node.position?.precisionBits ?? 0); + + const safeText = + node.user?.shortName ?? + numberToHexUnpadded(node.num).slice(-4).toUpperCase(); + const color = getColorFromText(safeText); + const isLight = isLightColor(color); + + const key = `${lat},${lng}:${radiusM}`; + + if (!unique.has(key)) { + unique.set(key, { + lng, + lat, + radiusM, + r: color.r, + g: color.g, + b: color.b, + a: isLight ? 0.3 : 0.2, // light colors need more alpha to be as visible + }); + } + } + + const items = Array.from(unique.values()).sort( + (a, b) => a.radiusM - b.radiusM, + ); + + const features: Feature[] = items.map( + ({ lng, lat, radiusM, r, g, b, a }) => { + const feat = circle([lng, lat], radiusM, { + steps: 64, + units: "meters", + }) as Feature; + feat.properties = { r, g, b, a, sortKey: -radiusM }; + return feat; + }, + ); + return { type: "FeatureCollection", features }; +} + +export const SourcePrecisionCircles = ({ + data, + id, + isVisible, +}: { + data: FeatureCollection; + id: string; + isVisible: boolean; +}) => { + return ( + + + + + ); +}; + +export const PrecisionLayer = ({ + id, + filteredNodes, + isVisible, +}: PrecisionLayerProps): React.ReactNode => { + return ( + + ); +}; diff --git a/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx new file mode 100644 index 00000000..febb3a3c --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx @@ -0,0 +1,341 @@ +import { Mono } from "@app/components/generic/Mono"; +import { cn } from "@app/core/utils/cn"; +import { getSignalColor } from "@app/core/utils/signalColor"; +import type { VisibilityState } from "@components/PageComponents/Map/Tools/MapLayerTool"; +import { useDevice } from "@core/stores"; +import { + distanceMeters, + hasPos, + type LngLat, + lngLatToMercator, + mercatorToLngLat, + toLngLat, +} from "@core/utils/geo"; +import type { Protobuf } from "@meshtastic/core"; +import type { Feature, FeatureCollection } from "geojson"; +import { useTranslation } from "react-i18next"; +import { Layer, Source } from "react-map-gl/maplibre"; + +const ARC_SEGMENTS = 32; +const ARC_OFFSET = 0.01; // 1% of distance +const MIN_LEN = 1e-3; // meters + +export interface SNRLayerProps { + id: string; + filteredNodes: Protobuf.Mesh.NodeInfo[]; + myNode: Protobuf.Mesh.NodeInfo | undefined; + visibilityState: VisibilityState; +} + +export interface SNRTooltipProps { + pos: { x: number; y: number }; + snr: number; + from: string; + to: string; +} + +type NeighborPlus = Protobuf.Mesh.Neighbor & { + num: number | undefined; + position: Protobuf.Mesh.Position | undefined; +}; +type NeighborInfoPlus = Omit & { + neighbors: NeighborPlus[]; +}; + +type RemoteInfo = { + type: "remote"; + node: Protobuf.Mesh.NodeInfo; + neighborInfo: NeighborInfoPlus; +}; + +type DirectInfo = { + type: "direct"; + from: Protobuf.Mesh.NodeInfo; + to: Protobuf.Mesh.NodeInfo; + snr: number; +}; + +type NeighborInfos = RemoteInfo | DirectInfo; + +type Pair = { + a: number; + b: number; + ab?: number; // SNR a->b + ba?: number; // SNR b->a +}; + +function arcSegment( + aLngLat: LngLat, + bLngLat: LngLat, + curved: boolean, +): LngLat[] | undefined { + if (!curved && distanceMeters(aLngLat, bLngLat) < MIN_LEN) { + // Straight line + return [aLngLat, bLngLat]; + } + const [ax, ay] = lngLatToMercator(aLngLat); + const [bx, by] = lngLatToMercator(bLngLat); + + const dx = bx - ax, + dy = by - ay; + const len = Math.hypot(dx, dy); + if (len < MIN_LEN) { + return undefined; + } + + const offsetMeters = curved ? len * ARC_OFFSET : 0; + + // Unit direction A->B and its left-hand normal + const ux = dx / len, + uy = dy / len; + const nx = -uy, + ny = ux; + + // Control point at the midpoint, offset to the left by offsetMeters + const mx = (ax + bx) * 0.5, + my = (ay + by) * 0.5; + const cx = mx + nx * offsetMeters, + cy = my + ny * offsetMeters; + + const coords: LngLat[] = []; + for (let i = 0; i <= ARC_SEGMENTS; i++) { + const t = i / ARC_SEGMENTS; + const omt = 1 - t; + // Quadratic Bézier: B(t) = (1−t)^2*A + 2(1−t)t*C + t^2*B + const x = omt * omt * ax + 2 * omt * t * cx + t * t * bx; + const y = omt * omt * ay + 2 * omt * t * cy + t * t * by; + coords.push(mercatorToLngLat([x, y])); + } + return coords; +} + +function upsertPair( + fromId: number, + toId: number, + snr: number, + pairs: Map, + idToLngLat: Map, +): void { + if (fromId === toId || !idToLngLat.has(fromId) || !idToLngLat.has(toId)) { + return; + } + + const a = Math.min(fromId, toId); + const b = Math.max(fromId, toId); + const key = `${a}-${b}`; + + let pair = pairs.get(key); // pair might exist + if (!pair) { + pair = { a, b }; + } + + // Store best SNR for each direction + if (fromId === a) { + pair.ab = pair.ab && pair.ab > snr ? pair.ab : snr; + } else { + pair.ba = pair.ba && pair.ba > snr ? pair.ba : snr; + } + + pairs.set(key, pair); +} + +function makeFeature( + fromId: number, + toId: number, + fromPos: LngLat, + toPos: LngLat, + snr: number, + curved: boolean, +): Feature | undefined { + const segment = arcSegment(fromPos, toPos, curved); + + if (!segment) { + return undefined; + } + + return { + type: "Feature", + geometry: { type: "LineString", coordinates: segment }, + properties: { + color: getSignalColor(snr), + snr, + from: fromId, + to: toId, + }, + }; +} + +function pushIfFeature( + a: number, + b: number, + aPos: LngLat, + bPos: LngLat, + snr: number, + curved: boolean, + features: Feature[], +) { + const feat = makeFeature(a, b, aPos, bPos, snr, curved); + if (feat) { + features.push(feat); + } +} + +function generateNeighborLines( + neighborInfos: NeighborInfos[], +): FeatureCollection { + // Collect positions for all referenced nodes, discard pairs with missing positions + const idToLngLat = new Map(); + const ensure = (node?: Protobuf.Mesh.NodeInfo | NeighborPlus) => { + if (node?.num && hasPos(node.position) && !idToLngLat.has(node.num)) { + idToLngLat.set(node.num, toLngLat(node.position)); + } + }; + + for (const info of neighborInfos) { + if (info.type === "remote") { + ensure(info.node); + for (const neighbor of info.neighborInfo.neighbors) { + ensure(neighbor); + } + } else { + ensure(info.from); + ensure(info.to); + } + } + + // Coalesce into pairs + const pairs = new Map(); + for (const info of neighborInfos) { + if (info.type === "remote") { + // RemoteInfo object + const fromId = info.node.num; + for (const neighbor of info.neighborInfo.neighbors) { + upsertPair(fromId, neighbor.nodeId, neighbor.snr, pairs, idToLngLat); + } + } else { + // DirectInfo object + upsertPair(info.from.num, info.to.num, info.snr, pairs, idToLngLat); + } + } + + // Generate features + const features: Feature[] = []; + + for (const pair of pairs.values()) { + const aPos = idToLngLat.get(pair.a); + const bPos = idToLngLat.get(pair.b); + if (!aPos || !bPos) { + continue; + } + + if (pair.ab && pair.ba) { + // both directions → two arcs + pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, true, features); + pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, true, features); + } else { + // only one direction → straight + if (pair.ab) { + pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, false, features); + } + if (pair.ba) { + pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, false, features); + } + } + } + + return { type: "FeatureCollection", features }; +} + +export const SNRTooltip = ({ + pos, + snr, + from, + to, +}: Partial = {}) => { + const { t } = useTranslation(); + + if (!pos) { + return undefined; + } + return ( +
+
+ {from ?? ""} + + {to ?? ""} +
+
+ SNR: {snr?.toFixed?.(2) ?? t("unknown.shortName")} dB +
+
+ ); +}; + +export const SNRLayer = ({ + id, + filteredNodes, + myNode, + visibilityState, +}: SNRLayerProps): React.ReactNode => { + const { getNeighborInfo } = useDevice(); + + const remotePairs = visibilityState.remoteNeighbors + ? filteredNodes.flatMap((node) => { + const neighborInfo = getNeighborInfo(node.num); + return neighborInfo + ? [ + { + type: "remote" as const, + node, + neighborInfo: { + ...neighborInfo, + neighbors: neighborInfo.neighbors.map((n) => { + const node = filteredNodes.find( + (node) => node.num === n.nodeId, + ); + return { ...n, num: node?.num, position: node?.position }; + }), + }, + } as const, + ] + : []; + }) + : []; + + const directPairs = + visibilityState.directNeighbors && myNode + ? filteredNodes + .filter((node) => node.hopsAway === 0 && node.num !== myNode.num) + .map((to) => ({ + type: "direct" as const, + from: myNode, + to, + snr: to.snr ?? 0, + })) + : []; + + const featureCollection = generateNeighborLines([ + ...remotePairs, + ...directPairs, + ]); + + return ( + + + + ); +}; diff --git a/packages/web/src/components/PageComponents/Map/Layers/WaypointLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/WaypointLayer.tsx new file mode 100644 index 00000000..9c747987 --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Layers/WaypointLayer.tsx @@ -0,0 +1,94 @@ +import { NodeMarker } from "@components/PageComponents/Map/Markers/NodeMarker.tsx"; +import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx"; +import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx"; +import { WaypointDetail } from "@components/PageComponents/Map/Popups/WaypointDetail.tsx"; +import { useMapFitting } from "@core/hooks/useMapFitting"; +import { useDevice, type WaypointWithMetadata } from "@core/stores"; +import type { Protobuf } from "@meshtastic/core"; +import { useCallback } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; + +export interface WaypointLayerProps { + mapRef: MapRef | undefined; + myNode: Protobuf.Mesh.NodeInfo | undefined; + isVisible: boolean; + popupState: PopupState | undefined; + setPopupState: (state: PopupState | undefined) => void; +} + +import { toLngLat } from "@core/utils/geo.ts"; + +export const WaypointLayer = ({ + mapRef, + myNode, + isVisible, + popupState, + setPopupState, +}: WaypointLayerProps): React.ReactNode[] => { + const { waypoints } = useDevice(); + const { focusLngLat } = useMapFitting(mapRef); + + const onMarkerClick = useCallback( + (waypoint: WaypointWithMetadata, e: { originalEvent: MouseEvent }) => { + e.originalEvent?.stopPropagation(); + setPopupState({ type: "waypoint", waypoint }); + if (waypoint.longitudeI && waypoint.latitudeI) { + focusLngLat( + toLngLat({ + longitudeI: waypoint.longitudeI, + latitudeI: waypoint.latitudeI, + }), + ); + } + }, + [focusLngLat, setPopupState], + ); + + const rendered: React.ReactNode[] = []; + if (!isVisible) { + return rendered; + } + + for (const waypoint of waypoints) { + const [lng, lat] = toLngLat({ + latitudeI: waypoint.latitudeI, + longitudeI: waypoint.longitudeI, + }); + rendered.push( + onMarkerClick(waypoint, e)} + />, + ); + } + + if (popupState?.type === "waypoint") { + const [lng, lat] = toLngLat({ + latitudeI: popupState.waypoint.latitudeI, + longitudeI: popupState.waypoint.longitudeI, + }); + + rendered.push( + setPopupState(undefined)} + > + {}} + /> + , + ); + } + return rendered; +}; diff --git a/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx new file mode 100644 index 00000000..c5dade90 --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx @@ -0,0 +1,106 @@ +import { cn } from "@app/core/utils/cn"; +import type { PxOffset } from "@components/PageComponents/Map/cluster.ts"; +import { Avatar } from "@components/UI/Avatar.tsx"; +import { + Tooltip, + TooltipArrow, + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from "@components/UI/Tooltip.tsx"; +import { memo } from "react"; +import { Marker } from "react-map-gl/maplibre"; + +export const NodeMarker = memo(function NodeMarker({ + id, + lng, + lat, + label, + longLabel, + tooltipLabel, + hasError, + isFavorite, + offset, + avatarClassName, + isVisible = true, + onClick, +}: { + id: number; + lng: number; + lat: number; + label: string; + longLabel?: string; + tooltipLabel?: string; + hasError?: boolean; + isFavorite?: boolean; + offset?: PxOffset; + avatarClassName?: string; + isVisible?: boolean; + onClick: (id: number, e: { originalEvent: MouseEvent }) => void; +}) { + const [dx, dy] = offset ?? [0, 0]; + + const style = { + "--dx": `${dx}px`, + "--dy": `${dy}px`, + pointerEvents: "auto", + } as React.CSSProperties; + + return ( + + + + + + + + {tooltipLabel && ( // only show tooltip if there's a label + onClick(id, { originalEvent: e.nativeEvent })} + > + {tooltipLabel} + + + )} + + + + {longLabel && ( // only show label if there's a longLabel + + )} + + ); +}); diff --git a/packages/web/src/components/PageComponents/Map/Markers/StackBadge.tsx b/packages/web/src/components/PageComponents/Map/Markers/StackBadge.tsx new file mode 100644 index 00000000..8ddc0faa --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Markers/StackBadge.tsx @@ -0,0 +1,33 @@ +import { Marker } from "react-map-gl/maplibre"; + +type StackBadgeProps = { + lng: number; + lat: number; + count: number; // n = size-1 + isVisible?: boolean; + onClick: (e: { originalEvent: MouseEvent }) => void; +}; + +export const StackBadge = ({ + lng, + lat, + count, + isVisible = true, + onClick, +}: StackBadgeProps) => { + return ( + + + + ); +}; diff --git a/packages/web/src/components/PageComponents/Map/NodeDetail.tsx b/packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx similarity index 94% rename from packages/web/src/components/PageComponents/Map/NodeDetail.tsx rename to packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx index a553083d..4c40d687 100644 --- a/packages/web/src/components/PageComponents/Map/NodeDetail.tsx +++ b/packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx @@ -65,14 +65,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { className="text-green-600 mb-1.5" size={12} strokeWidth={3} - aria-label={t("node_detail_public_key_enabled_aria_label")} + aria-label={t("nodeDetail.publicKeyEnabled.label")} /> ) : ( )} @@ -158,7 +158,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { : node.hopsAway}
- {node.hopsAway === 1 ? t("unit.hops.one") : t("unit.hop.plural")} + {node.hopsAway === 1 ? t("unit.hop.one") : t("unit.hop.plural")}
{node.position?.altitude && ( @@ -166,7 +166,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{formatQuantity(node.position?.altitude, { @@ -181,7 +181,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{!!node.deviceMetrics?.channelUtilization && (
-
{t("nodeDetail.channelUtilization")}
+
{t("channelUtilization.short")}
{node.deviceMetrics?.channelUtilization.toPrecision(3)}% @@ -189,7 +189,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { )} {!!node.deviceMetrics?.airUtilTx && (
-
{t("nodeDetail.airTxUtilization")}
+
{t("airtimeUtilization.short")}
{node.deviceMetrics?.airUtilTx.toPrecision(3)}% diff --git a/packages/web/src/components/PageComponents/Map/Popups/PopupWrapper.tsx b/packages/web/src/components/PageComponents/Map/Popups/PopupWrapper.tsx new file mode 100644 index 00000000..c06c0a08 --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Popups/PopupWrapper.tsx @@ -0,0 +1,38 @@ +import type { PxOffset } from "@components/PageComponents/Map/cluster.ts"; +import type { WaypointWithMetadata } from "@core/stores"; +import { memo } from "react"; +import { Popup } from "react-map-gl/maplibre"; + +export type PopupState = + | { type: "node"; num: number; offset: PxOffset } + | { type: "waypoint"; waypoint: WaypointWithMetadata }; + +export const PopupWrapper = memo(function SelectedNodePopup({ + lng, + lat, + offset, + onClose, + children, +}: { + lng: number; + lat: number; + offset?: PxOffset; + onClose: () => void; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +}); diff --git a/packages/web/src/components/PageComponents/Map/Popups/WaypointDetail.tsx b/packages/web/src/components/PageComponents/Map/Popups/WaypointDetail.tsx new file mode 100644 index 00000000..68a1479e --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Popups/WaypointDetail.tsx @@ -0,0 +1,191 @@ +import { TimeAgo } from "@components/generic/TimeAgo"; +import { Separator } from "@components/UI/Separator.tsx"; +import type { WaypointWithMetadata } from "@core/stores"; +import { useNodeDB } from "@core/stores"; +import { + bearingDegrees, + distanceMeters, + hasPos, + toLngLat, +} from "@core/utils/geo"; +import type { Protobuf } from "@meshtastic/core"; +import { + ClockFadingIcon, + ClockPlusIcon, + CompassIcon, + MapPinnedIcon, + MoveHorizontalIcon, + NavigationIcon, + RotateCwIcon, + UserLockIcon, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface WaypointDetailProps { + waypoint: WaypointWithMetadata; + myNode?: Protobuf.Mesh.NodeInfo; + onEdit: () => void; +} + +export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => { + const { t } = useTranslation("map"); + const { getNode } = useNodeDB(); + + const waypointLngLat = toLngLat({ + latitudeI: waypoint.latitudeI, + longitudeI: waypoint.longitudeI, + }); + + const distance = hasPos(myNode?.position) + ? distanceMeters(toLngLat(myNode?.position), waypointLngLat) + : undefined; + + const bearing = hasPos(myNode?.position) + ? bearingDegrees(toLngLat(myNode?.position), waypointLngLat) + : undefined; + + return ( +
+
+

+ {String.fromCodePoint(waypoint.icon) ?? "📍"} + {waypoint.name} +

+
+ + {waypoint.description && ( +

{waypoint.description}

+ )} + + + +
+
+ {/* Coordinates */} +
+
+ + + {t("waypointDetail.longitude")} +
+ {t("waypointDetail.latitude")} +
+
+
+ {waypointLngLat[0]} +
+ {waypointLngLat[1]} +
+
+ + {/* Created */} +
+
+ + + {t("waypointDetail.createdDate")} + +
+
+ +
+
+ + {/* Updated */} + {waypoint.metadata.updated && ( +
+
+ + {t("waypointDetail.updated")} +
+
+ +
+
+ )} + + {/* Expires */} + {waypoint.expire !== 0 && ( +
+
+ + {t("waypointDetail.expires")} +
+
+ +
+
+ )} + + {/* Distance */} + {distance != null && ( +
+
+ + {t("waypointDetail.distance")} +
+
+ + {Math.round(distance)}{" "} + {distance === 1 + ? t("unit.meter.one") + : t("unit.meter.plural")} + +
+
+ )} + + {/* Bearing */} + {bearing != null && ( +
+
+ + {t("waypointDetail.bearing")} +
+
+ + {Math.round(bearing)} + {t("unit.degree.suffix")} +
+
+ )} + + {/* Locked To */} + {waypoint.lockedTo && ( +
+
+ + {t("waypointDetail.lockedTo")} +
+
+ {getNode(waypoint.lockedTo)?.user?.longName ?? + t("unknown.longName")} +
+
+ )} +
+
+
+ ); +}; diff --git a/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx b/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx new file mode 100644 index 00000000..9a3557ff --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx @@ -0,0 +1,143 @@ +import { Checkbox } from "@components/UI/Checkbox/index.tsx"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@components/UI/Popover.tsx"; +import { cn } from "@core/utils/cn.ts"; +import { LayersIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +export interface VisibilityState { + nodeMarkers: boolean; + directNeighbors: boolean; + remoteNeighbors: boolean; + positionPrecision: boolean; + traceroutes: boolean; + waypoints: boolean; +} + +export const defaultVisibilityState: VisibilityState = { + nodeMarkers: true, + directNeighbors: false, + remoteNeighbors: false, + positionPrecision: false, + traceroutes: false, + waypoints: true, +}; + +interface MapLayerToolProps { + visibilityState: VisibilityState; + setVisibilityState: (state: VisibilityState) => void; +} + +interface CheckboxProps { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + className?: string; +} + +const CheckboxItem = ({ + label, + checked, + onChange, + className, +}: CheckboxProps) => { + return ( + + {label} + + ); +}; + +export function MapLayerTool({ + visibilityState, + setVisibilityState, +}: MapLayerToolProps): ReactNode { + const { t } = useTranslation("map"); + + return ( + + + + + + { + setVisibilityState({ ...visibilityState, nodeMarkers: checked }); + }} + /> + { + setVisibilityState({ ...visibilityState, waypoints: checked }); + }} + /> + { + setVisibilityState({ + ...visibilityState, + directNeighbors: checked, + }); + }} + /> + { + setVisibilityState({ + ...visibilityState, + remoteNeighbors: checked, + }); + }} + /> + { + setVisibilityState({ + ...visibilityState, + positionPrecision: checked, + }); + }} + /> + {/* { + setVisibilityState({ ...visibilityState, traceroutes: checked }); + }} + />*/} + + + ); +} diff --git a/packages/web/src/components/PageComponents/Map/cluster.ts b/packages/web/src/components/PageComponents/Map/cluster.ts new file mode 100644 index 00000000..668ac619 --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/cluster.ts @@ -0,0 +1,56 @@ +import type { Protobuf } from "@meshtastic/core"; + +export type ClusterKey = string; +export type PxOffset = [number, number]; + +export function makeClusterKey(pos: Protobuf.Mesh.Position): ClusterKey { + return `${pos.latitudeI},${pos.longitudeI}`; +} + +export function groupNodesByIdenticalCoords( + nodes: Protobuf.Mesh.NodeInfo[], +): Map { + const map = new Map(); + for (const node of nodes) { + if (!node.position) { + continue; + } + + const key = makeClusterKey(node.position); + const arr = map.get(key); + if (arr) { + arr.push(node); + } else { + map.set(key, [node]); + } + } + return map; +} + +export function hashToAngle(key: string): number { + // djb2 + let h = 5381; + for (let i = 0; i < key.length; i++) { + h = (h << 5) + h + key.charCodeAt(i); + } + // Map to [0, 2π) + return ((h >>> 0) % 360) * (Math.PI / 180); +} + +export function fanOutOffsetsPx(size: number, key: string): Array { + const R = 10 + 5 * size; // radius in pixels + const base = hashToAngle(key); + const out: Array = []; + + if (size === 1) { + return [[0, 0]]; + } + + for (let i = 0; i < size; i++) { + const theta = base + (i * 2 * Math.PI) / size; + const dx = R * Math.cos(theta); + const dy = R * Math.sin(theta); + out.push([dx, dy]); + } + return out; +} diff --git a/packages/web/src/components/UI/Avatar.tsx b/packages/web/src/components/UI/Avatar.tsx index 2577eea4..12a1ea48 100644 --- a/packages/web/src/components/UI/Avatar.tsx +++ b/packages/web/src/components/UI/Avatar.tsx @@ -44,12 +44,17 @@ export const Avatar = ({ `relative flex items-center justify-center rounded-full font-semibold `, sizes[size], + "bg-[rgb(var(--bg-r),var(--bg-g),var(--bg-b))]", // allow override with className className, )} - style={{ - backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`, - color: textColor, - }} + style={ + { + "--bg-r": bgColor.r, + "--bg-g": bgColor.g, + "--bg-b": bgColor.b, + color: textColor, + } as React.CSSProperties + } > {showFavorite ? ( diff --git a/packages/web/src/components/UI/Tooltip.tsx b/packages/web/src/components/UI/Tooltip.tsx index 79164bc9..606d172a 100644 --- a/packages/web/src/components/UI/Tooltip.tsx +++ b/packages/web/src/components/UI/Tooltip.tsx @@ -26,10 +26,13 @@ const TooltipContent = React.forwardRef< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; +const TooltipPortal = TooltipPrimitive.Portal; + export { Tooltip, TooltipArrow, TooltipContent, TooltipProvider, TooltipTrigger, + TooltipPortal, }; diff --git a/packages/web/src/components/generic/TimeAgo.tsx b/packages/web/src/components/generic/TimeAgo.tsx index f5fae897..6d18c367 100755 --- a/packages/web/src/components/generic/TimeAgo.tsx +++ b/packages/web/src/components/generic/TimeAgo.tsx @@ -90,7 +90,7 @@ export const TimeAgo = ({ useEffect(() => { const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); - let timerId: number; + let timerId: ReturnType; const update = () => { const { value, unit } = getRelativeTimeParts(date); diff --git a/packages/web/src/core/hooks/useMapFitting.ts b/packages/web/src/core/hooks/useMapFitting.ts new file mode 100644 index 00000000..fe7e30c5 --- /dev/null +++ b/packages/web/src/core/hooks/useMapFitting.ts @@ -0,0 +1,50 @@ +import { boundsFromLngLat, type LngLat, toLngLat } from "@core/utils/geo"; +import type { Protobuf } from "@meshtastic/core"; +import { useCallback } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; + +export function useMapFitting(map: MapRef | undefined) { + const focusLngLat = useCallback( + (position: LngLat) => { + if (!map) { + return; + } + const [lng, lat] = position; + map.easeTo({ + center: [lng, lat], + zoom: map.getZoom(), + }); + }, + [map], + ); + + const fitToNodes = useCallback( + (nodes: Protobuf.Mesh.NodeInfo[]) => { + if (!map || nodes.length === 0) { + return; + } + + if (nodes.length === 1 && nodes[0]) { + return focusLngLat(toLngLat(nodes[0].position)); + } + + // Build [lng, lat] coords, then let boundsFromLngLat do the turf dance + const coords = nodes.map((n) => toLngLat(n.position)); + const bounds = boundsFromLngLat(coords); + if (!bounds) { + return; + } + + const center = map.cameraForBounds(bounds, { + padding: { top: 10, bottom: 10, left: 10, right: 10 }, + }); + + if (center) { + map.easeTo(center); + } + }, + [map, focusLngLat], + ); + + return { focusLngLat, fitToNodes }; +} diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index d1f21d93..e8354706 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -22,6 +22,15 @@ export type ValidModuleConfigType = Exclude< undefined >; +export type WaypointWithMetadata = Protobuf.Mesh.Waypoint & { + metadata: { + channel: number; // Channel on which the waypoint was received + created: Date; // Timestamp when the waypoint was received + updated?: Date; // Timestamp when the waypoint was last updated + from: number; // Node number of the device that sent the waypoint + }; +}; + export interface Device { id: number; status: Types.DeviceStatusEnum; @@ -39,7 +48,8 @@ export interface Device { >; connection?: MeshDevice; activeNode: number; - waypoints: Protobuf.Mesh.Waypoint[]; + waypoints: WaypointWithMetadata[]; + neighborInfo: Map; pendingSettingsChanges: boolean; messageDraft: string; unreadCounts: Map; @@ -99,7 +109,12 @@ export interface Device { setActiveNode: (node: number) => void; setPendingSettingsChanges: (state: boolean) => void; addChannel: (channel: Protobuf.Channel.Channel) => void; - addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => void; + addWaypoint: ( + waypoint: Protobuf.Mesh.Waypoint, + channel: Types.ChannelNumber, + from: number, + rxTime: Date, + ) => void; addConnection: (connection: MeshDevice) => void; addTraceRoute: ( traceroute: Types.PacketMetadata, @@ -120,6 +135,11 @@ export interface Device { getClientNotification: ( index: number, ) => Protobuf.Mesh.ClientNotification | undefined; + addNeighborInfo: ( + nodeNum: number, + neighborInfo: Protobuf.Mesh.NeighborInfo, + ) => void; + getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined; } export interface DeviceState { @@ -156,6 +176,7 @@ export const useDeviceStore = createStore((set, get) => ({ connection: undefined, activeNode: 0, waypoints: [], + neighborInfo: new Map(), dialog: { import: false, QR: false, @@ -543,7 +564,7 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => { + addWaypoint: (waypoint, channel, from, rxTime) => { set( produce((draft) => { const device = draft.devices.get(id); @@ -552,9 +573,19 @@ export const useDeviceStore = createStore((set, get) => ({ (wp) => wp.id === waypoint.id, ); if (index !== -1) { - device.waypoints[index] = waypoint; + const created = + device.waypoints[index]?.metadata.created ?? new Date(); + const updatedWaypoint = { + ...waypoint, + metadata: { created, updated: rxTime, from, channel }, + }; + + device.waypoints[index] = updatedWaypoint; } else { - device.waypoints.push(waypoint); + device.waypoints.push({ + ...waypoint, + metadata: { created: rxTime, from, channel }, + }); } } }), @@ -720,6 +751,30 @@ export const useDeviceStore = createStore((set, get) => ({ } return device.clientNotifications[index]; }, + addNeighborInfo: ( + nodeId: number, + neighborInfo: Protobuf.Mesh.NeighborInfo, + ) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + // Replace any existing neighbor info for this nodeId + device.neighborInfo.set(nodeId, neighborInfo); + }), + ); + }, + + getNeighborInfo: (nodeNum: number) => { + const device = get().devices.get(id); + if (!device) { + return; + } + return device.neighborInfo.get(nodeNum); + }, }); }), ); diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts index 6a43e296..4cf74c94 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -16,6 +16,7 @@ export { useDeviceStore, type ValidConfigType, type ValidModuleConfigType, + type WaypointWithMetadata, } from "@core/stores/deviceStore"; export { MessageState, diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts index 627e0b34..14bc40a6 100644 --- a/packages/web/src/core/stores/nodeDBStore/index.ts +++ b/packages/web/src/core/stores/nodeDBStore/index.ts @@ -41,7 +41,7 @@ export interface NodeDB { filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, includeSelf?: boolean, ) => Protobuf.Mesh.NodeInfo[]; - getMyNode: () => Protobuf.Mesh.NodeInfo; + getMyNode: () => Protobuf.Mesh.NodeInfo | undefined; getNodeError: (nodeNum: number) => NodeError | undefined; hasNodeError: (nodeNum: number) => boolean; @@ -378,13 +378,12 @@ function nodeDBFactory( if (!nodeDB) { throw new Error(`No nodeDB found (id: ${id})`); } - if (!nodeDB.myNodeNum) { - throw new Error(`No myNodeNum set for nodeDB with id: ${id}`); + if (nodeDB.myNodeNum) { + return ( + nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? + create(Protobuf.Mesh.NodeInfoSchema) + ); } - return ( - nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? - create(Protobuf.Mesh.NodeInfoSchema) - ); }, getNodeError: (nodeNum) => { diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx index 420a20b1..a5482711 100644 --- a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx @@ -136,16 +136,16 @@ describe("NodeDB store", () => { expect(db.getNodeError(10)).toBeUndefined(); }); - it("getMyNode throws before setNodeNum; works after", async () => { + it("getMyNode returns undefined before setNodeNum; works after", async () => { const { useNodeDBStore } = await freshStore(); const db = useNodeDBStore.getState().addNodeDB(1); db.addNode(makeNode(123)); - expect(() => db.getMyNode()).toThrow(); + expect(db.getMyNode()).toBeUndefined(); db.setNodeNum(123); const me = db.getMyNode(); - expect(me.num).toBe(123); + expect(me?.num).toBe(123); }); it("setNodeNum merges with existing DB with same myNodeNum", async () => { diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 4c4dd4b7..78f0bd80 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -51,8 +51,8 @@ export const subscribeAll = ( }); connection.events.onWaypointPacket.subscribe((waypoint) => { - const { data } = waypoint; - device.addWaypoint(data); + const { data, channel, from, rxTime } = waypoint; + device.addWaypoint(data, channel, from, rxTime); }); connection.events.onMyNodeInfo.subscribe((nodeInfo) => { @@ -127,6 +127,10 @@ export const subscribeAll = ( }, ); + connection.events.onNeighborInfoPacket.subscribe((neighborInfo) => { + device.addNeighborInfo(neighborInfo.from, neighborInfo.data); + }); + connection.events.onRoutingPacket.subscribe((routingPacket) => { if (routingPacket.data.variant.case === "errorReason") { switch (routingPacket.data.variant.value) { diff --git a/packages/web/src/core/utils/geo.ts b/packages/web/src/core/utils/geo.ts new file mode 100644 index 00000000..a3017bd9 --- /dev/null +++ b/packages/web/src/core/utils/geo.ts @@ -0,0 +1,87 @@ +import { bbox, lineString } from "@turf/turf"; + +export type LngLat = [number, number]; +export type Mercator = [number, number]; +export type Bounds = [[number, number], [number, number]]; + +const INT_DEG = 1e7; +const EARTH_RADIUS = 6378137; + +export const toLngLat = (position?: { + latitudeI?: number; + longitudeI?: number; +}): LngLat => [ + (position?.longitudeI ?? 0) / INT_DEG, + (position?.latitudeI ?? 0) / INT_DEG, +]; + +export const hasPos = (position?: { + latitudeI?: number; + longitudeI?: number; +}) => + Number.isFinite(position?.latitudeI) && + Number.isFinite(position?.longitudeI) && + !(position?.latitudeI === 0 && position?.longitudeI === 0); + +export const boundsFromLngLat = (coords: LngLat[]): Bounds | undefined => { + if (coords.length === 0) { + return undefined; + } + + const turfCoords = coords.map(([lng, lat]) => [lat, lng]); + const [minLat, minLng, maxLat, maxLng] = bbox(lineString(turfCoords)); + + return [ + [minLng, minLat], + [maxLng, maxLat], + ]; +}; + +const deg2rad = (d: number) => (d * Math.PI) / 180; +const rad2deg = (r: number) => (r * 180) / Math.PI; + +export function lngLatToMercator([lng, lat]: LngLat): Mercator { + return [ + EARTH_RADIUS * deg2rad(lng), + EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + deg2rad(lat) / 2)), + ]; +} + +export function mercatorToLngLat([x, y]: Mercator): LngLat { + return [ + rad2deg(x / EARTH_RADIUS), + rad2deg(2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2), + ]; +} + +export function distanceMeters([lng1, lat1]: LngLat, [lng2, lat2]: LngLat) { + const phi1 = deg2rad(lat1), + phi2 = deg2rad(lat2); + const x = deg2rad(lng2 - lng1) * Math.cos((phi1 + phi2) * 0.5); + const y = phi2 - phi1; + return EARTH_RADIUS * Math.hypot(x, y); +} + +export function precisionBitsToMeters(precisionBits: number): number { + const M_PER_DEG_EQ = (2 * Math.PI * EARTH_RADIUS) / 360; // ≈ 111_319.490793 m/deg + + const stepInt = 2 ** (32 - precisionBits); + const stepDegrees = stepInt / INT_DEG; + return Math.round(0.5 * stepDegrees * M_PER_DEG_EQ); +} + +export function bearingDegrees(from: LngLat, to: LngLat): number { + const [lambda1deg, phi1deg] = from; + const [lambda2deg, phi2deg] = to; + + const phi1 = deg2rad(phi1deg); + const phi2 = deg2rad(phi2deg); + const deltaLambda = deg2rad(lambda2deg - lambda1deg); + + const y = Math.sin(deltaLambda) * Math.cos(phi2); + const x = + Math.cos(phi1) * Math.sin(phi2) - + Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda); + + return (rad2deg(Math.atan2(y, x)) + 360) % 360; +} diff --git a/packages/web/src/core/utils/signalColor.ts b/packages/web/src/core/utils/signalColor.ts new file mode 100644 index 00000000..76ce8220 --- /dev/null +++ b/packages/web/src/core/utils/signalColor.ts @@ -0,0 +1,31 @@ +export const SNR_THRESHOLD = { + GOOD: -7, + FAIR: -15, +}; + +export const RSSI_THRESHOLD = { + GOOD: -115, + FAIR: -126, +}; + +export const LINE_COLOR = { + GOOD: "#00ff00", + FAIR: "#ffe600", + BAD: "#f7931a", +}; + +export const getSignalColor = (snr: number, rssi?: number): string => { + if ( + snr > SNR_THRESHOLD.GOOD && + (rssi == null || rssi > RSSI_THRESHOLD.GOOD) + ) { + return LINE_COLOR.GOOD; + } + if ( + snr > SNR_THRESHOLD.FAIR && + (rssi == null || rssi > RSSI_THRESHOLD.FAIR) + ) { + return LINE_COLOR.FAIR; + } + return LINE_COLOR.BAD; +}; diff --git a/packages/web/src/i18n-config.ts b/packages/web/src/i18n-config.ts index e40e804c..b673681a 100644 --- a/packages/web/src/i18n-config.ts +++ b/packages/web/src/i18n-config.ts @@ -58,5 +58,6 @@ i18next "messages", "nodes", "ui", + "map", ], }); diff --git a/packages/web/src/index.css b/packages/web/src/index.css index deb34025..5ab88c88 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -74,6 +74,12 @@ } } +.animate-fan-out { + transform: translate(var(--dx), var(--dy)); + transition: transform 200ms ease-out; /* expand AND collapse */ +} + + html { overflow: hidden; } diff --git a/packages/web/src/pages/Map/index.tsx b/packages/web/src/pages/Map/index.tsx index 69d40a78..91cf5bbc 100644 --- a/packages/web/src/pages/Map/index.tsx +++ b/packages/web/src/pages/Map/index.tsx @@ -1,64 +1,76 @@ +import { + defaultVisibilityState, + MapLayerTool, + type VisibilityState, +} from "@app/components/PageComponents/Map/Tools/MapLayerTool.tsx"; import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; import { type FilterState, useFilterNode, } from "@components/generic/Filter/useFilterNode.ts"; import { BaseMap } from "@components/Map.tsx"; +import { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx"; +import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx"; +import { + SNRLayer, + SNRTooltip, + type SNRTooltipProps, +} from "@components/PageComponents/Map/Layers/SNRLayer.tsx"; +import { WaypointLayer } from "@components/PageComponents/Map/Layers/WaypointLayer.tsx"; +import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; -import { useDevice, useNodeDB } from "@core/stores"; +import { useMapFitting } from "@core/hooks/useMapFitting.ts"; +import { useNodeDB } from "@core/stores"; import { cn } from "@core/utils/cn.ts"; +import { hasPos, toLngLat } from "@core/utils/geo.ts"; import type { Protobuf } from "@meshtastic/core"; -import { bbox, lineString } from "@turf/turf"; -import { FunnelIcon, MapPinIcon } from "lucide-react"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import { FunnelIcon, LocateFixedIcon } from "lucide-react"; import { useCallback, useDeferredValue, + useId, useMemo, useRef, useState, } from "react"; -import { Marker, Popup, useMap } from "react-map-gl/maplibre"; -import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx"; -import { Avatar } from "../../components/UI/Avatar.tsx"; +import { useTranslation } from "react-i18next"; +import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre"; const NODEDB_DEBOUNCE_MS = 250; -type NodePosition = { - latitude: number; - longitude: number; -}; - -const convertToLatLng = (position?: { - latitudeI?: number; - longitudeI?: number; -}): NodePosition => ({ - latitude: (position?.latitudeI ?? 0) / 1e7, - longitude: (position?.longitudeI ?? 0) / 1e7, -}); - const MapPage = () => { - const { waypoints } = useDevice(); - const { nodes: validNodes, hasNodeError } = useNodeDB( + const { t } = useTranslation("map"); + const { getNode } = useNodeDB(); + const { nodes: validNodes, myNode } = useNodeDB( (db) => ({ // only nodes with a position nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo => Boolean(n.position?.latitudeI), ), - hasNodeError: db.hasNodeError, - // include the Map reference so error badges update when nodeErrors changes + myNode: db.getMyNode(), + + // References to cause re-render on change _errorsRef: db.nodeErrors, + _nodeNumRef: db.myNodeNum, }), { debounce: NODEDB_DEBOUNCE_MS }, ); - const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); + const { default: mapRef } = useMap(); + const { focusLngLat, fitToNodes } = useMapFitting(mapRef); - const { default: map } = useMap(); + const hasFitBoundsOnce = useRef(false); + const [snrHover, setSnrHover] = useState(); + const [expandedCluster, setExpandedCluster] = useState(); + const [popupState, setPopupState] = useState(); - const [selectedNode, setSelectedNode] = - useState(null); + const [visibilityState, setVisibilityState] = useState( + () => defaultVisibilityState, + ); + // Filters const [filterState, setFilterState] = useState( () => defaultFilterValues, ); @@ -69,143 +81,187 @@ const MapPage = () => { [validNodes, deferredFilterState, nodeFilter], ); - const hasFitBoundsOnce = useRef(false); + // Map fitting + const getMapBounds = useCallback(() => { + if (!hasFitBoundsOnce.current) { + fitToNodes(validNodes); + hasFitBoundsOnce.current = true; + } + }, [fitToNodes, validNodes]); + + // SNR lines + const snrLayerElementId = useId(); + const snrLayerElement = useMemo( + () => ( + + ), + [filteredNodes, myNode, visibilityState, snrLayerElementId], + ); + + const onMouseMove = useCallback( + (event: MapLayerMouseEvent) => { + const { + features, + point: { x, y }, + } = event; + const hoveredFeature = features?.[0]; + + if (hoveredFeature) { + const { from, to, snr } = hoveredFeature.properties; - const handleMarkerClick = useCallback( - (node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => { - event?.originalEvent?.stopPropagation(); + const fromLong = + getNode(from)?.user?.longName ?? + t("fallbackName", { + last4: numberToHexUnpadded(from).slice(-4).toUpperCase(), + }); - setSelectedNode(node); + const toLong = + getNode(to)?.user?.longName ?? + t("fallbackName", { + last4: numberToHexUnpadded(to).slice(-4).toUpperCase(), + }); - if (map) { - const position = convertToLatLng(node.position); - map.easeTo({ - center: [position.longitude, position.latitude], - zoom: map?.getZoom(), - }); + setSnrHover({ pos: { x, y }, snr, from: fromLong, to: toLong }); + } else { + setSnrHover(undefined); } }, - [map], + [getNode, t], ); - // Get the bounds of the map based on the nodes furtherest away from center - const getMapBounds = useCallback(() => { - if (hasFitBoundsOnce.current || !map || validNodes.length === 0) { - return; - } + // Node markers & clusters + const onMapBackgroundClick = useCallback(() => { + setExpandedCluster(undefined); + }, []); - if (validNodes.length === 1 && validNodes[0]) { - map.easeTo({ - zoom: map.getZoom(), - center: [ - (validNodes[0].position?.longitudeI ?? 0) / 1e7, - (validNodes[0].position?.latitudeI ?? 0) / 1e7, - ], - }); - return; - } + const markerElements = useMemo( + () => ( + + ), + [ + filteredNodes, + expandedCluster, + mapRef, + myNode, + popupState, + visibilityState.nodeMarkers, + ], + ); - const line = lineString( - validNodes.map((n) => [ - (n.position?.latitudeI ?? 0) / 1e7, - (n.position?.longitudeI ?? 0) / 1e7, - ]), - ); - const bounds = bbox(line); - const center = map.cameraForBounds( - [ - [bounds[1], bounds[0]], - [bounds[3], bounds[2]], - ], - { padding: { top: 10, bottom: 10, left: 10, right: 10 } }, - ); - if (center) { - map.easeTo(center); - } - hasFitBoundsOnce.current = true; - }, [map, validNodes]); - - // Generate all markers - const markers = useMemo( - () => - filteredNodes.map((node) => { - const position = convertToLatLng(node.position); - return ( - handleMarkerClick(node, e)} - > - - - ); - }), - [filteredNodes, handleMarkerClick, hasNodeError], + // Precision circles + const precisionCirclesElementId = useId(); + const precisionCirclesElement = useMemo( + () => ( + + ), + [ + filteredNodes, + visibilityState.positionPrecision, + precisionCirclesElementId, + ], + ); + + // Waypoints + const waypointLayerElement = useMemo( + () => ( + + ), + [mapRef, myNode, visibilityState.waypoints, popupState], ); return ( }> - - {waypoints.map((wp) => ( - -
- -
-
- ))} - {markers} - {selectedNode && - (() => { - const position = convertToLatLng(selectedNode.position); - return ( - setSelectedNode(null)} - className="w-full" - > - - - ); - })()} + + {markerElements} + {snrLayerElement} + {precisionCirclesElement} + {waypointLayerElement} + + {snrHover && ( + + )} +
+ {myNode && hasPos(myNode?.position) && ( + + )} - , - showTextSearch: true, - }} - /> + , + showTextSearch: true, + }} + /> + + +
); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c010ac48..27e7ffef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: '@types/chrome': specifier: ^0.1.0 version: 0.1.3 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6