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