Browse Source
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 <[email protected]>pull/850/head
31 changed files with 1853 additions and 181 deletions
@ -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" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,174 @@ |
|||||
|
import { |
||||
|
fanOutOffsetsPx, |
||||
|
groupNodesByIdenticalCoords, |
||||
|
type PxOffset, |
||||
|
} from "@components/PageComponents/Map/cluster.ts"; |
||||
|
import { |
||||
|
generatePrecisionCircles, |
||||
|
SourcePrecisionCircles, |
||||
|
} from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx"; |
||||
|
import { NodeMarker } from "@components/PageComponents/Map/Markers/NodeMarker.tsx"; |
||||
|
import { StackBadge } from "@components/PageComponents/Map/Markers/StackBadge.tsx"; |
||||
|
import { NodeDetail } from "@components/PageComponents/Map/Popups/NodeDetail.tsx"; |
||||
|
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx"; |
||||
|
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx"; |
||||
|
import { useMapFitting } from "@core/hooks/useMapFitting"; |
||||
|
import { useNodeDB } from "@core/stores"; |
||||
|
import { hasPos, toLngLat } from "@core/utils/geo.ts"; |
||||
|
import type { Protobuf } from "@meshtastic/core"; |
||||
|
import { useCallback, useMemo } from "react"; |
||||
|
import { useTranslation } from "react-i18next"; |
||||
|
import type { MapRef } from "react-map-gl/maplibre"; |
||||
|
|
||||
|
export interface NodeMarkerProps { |
||||
|
mapRef: MapRef | undefined; |
||||
|
filteredNodes: Protobuf.Mesh.NodeInfo[]; |
||||
|
myNode: Protobuf.Mesh.NodeInfo | undefined; |
||||
|
expandedCluster: string | undefined; |
||||
|
setExpandedCluster: (key: string | undefined) => 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
|
||||
|
nodes.forEach((node, i) => { |
||||
|
const isHead = i === 0; |
||||
|
|
||||
|
rendered.push( |
||||
|
<NodeMarker |
||||
|
key={`node-${key}-${node.num}`} |
||||
|
id={node.num} |
||||
|
lng={lng} |
||||
|
lat={lat} |
||||
|
offset={expandedOffsets?.[i]} |
||||
|
label={node.user?.shortName ?? t("unknown.shortName")} |
||||
|
tooltipLabel={node.user?.longName ?? t("unknown.longName")} |
||||
|
hasError={hasNodeError(node.num)} |
||||
|
isFavorite={node.isFavorite ?? false} |
||||
|
isVisible={isVisible} |
||||
|
onClick={(num, e) => { |
||||
|
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( |
||||
|
<StackBadge |
||||
|
key={`stack-badge-${key}`} |
||||
|
lng={lng} |
||||
|
lat={lat} |
||||
|
count={nodes.length - 1} |
||||
|
isVisible={isVisible && !isExpanded} |
||||
|
onClick={(e) => { |
||||
|
e.originalEvent?.stopPropagation(); |
||||
|
setExpandedCluster(key); |
||||
|
}} |
||||
|
/>, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (selectedNode) { |
||||
|
rendered.push( |
||||
|
<SourcePrecisionCircles |
||||
|
key={`precision-circles-selected-${selectedNode.num}`} |
||||
|
data={generatePrecisionCircles([selectedNode])} |
||||
|
id={`precisionCircles-selected-${selectedNode.num}`} |
||||
|
isVisible={true} |
||||
|
/>, |
||||
|
); |
||||
|
|
||||
|
const [lng, lat] = toLngLat(selectedNode.position); |
||||
|
|
||||
|
rendered.push( |
||||
|
<PopupWrapper |
||||
|
key={`popup-nodeinfo-${selectedNode.num}`} |
||||
|
lng={lng} |
||||
|
lat={lat} |
||||
|
offset={popupState?.type === "node" ? popupState.offset : [0, 0]} |
||||
|
onClose={() => setPopupState(undefined)} |
||||
|
> |
||||
|
<NodeDetail node={selectedNode} /> |
||||
|
</PopupWrapper>, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (myNode && hasPos(myNode.position)) { |
||||
|
const [lng, lat] = toLngLat(myNode.position); |
||||
|
rendered.push( |
||||
|
<NodeMarker |
||||
|
key={`node-${myNode.num}`} |
||||
|
id={myNode.num} |
||||
|
lng={lng} |
||||
|
lat={lat} |
||||
|
label={myNode.user?.shortName?.toString() ?? String(myNode.num)} |
||||
|
tooltipLabel={t("myNode.tooltip")} |
||||
|
hasError={false} |
||||
|
isFavorite={true} |
||||
|
onClick={(_, e) => onMarkerClick(myNode.num, [0, 0], e)} |
||||
|
/>, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return rendered; |
||||
|
}; |
||||
@ -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<Polygon, CircleProps>[] = items.map( |
||||
|
({ lng, lat, radiusM, r, g, b, a }) => { |
||||
|
const feat = circle([lng, lat], radiusM, { |
||||
|
steps: 64, |
||||
|
units: "meters", |
||||
|
}) as Feature<Polygon, CircleProps>; |
||||
|
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 ( |
||||
|
<Source id={id} type="geojson" data={data}> |
||||
|
<Layer |
||||
|
id={`${id}-fill`} |
||||
|
type="fill" |
||||
|
layout={{ visibility: isVisible ? "visible" : "none" }} |
||||
|
paint={{ |
||||
|
"fill-color": [ |
||||
|
"rgba", |
||||
|
["get", "r"], |
||||
|
["get", "g"], |
||||
|
["get", "b"], |
||||
|
["get", "a"], |
||||
|
], |
||||
|
}} |
||||
|
/> |
||||
|
<Layer |
||||
|
id={`${id}-line`} |
||||
|
type="line" |
||||
|
layout={{ visibility: isVisible ? "visible" : "none" }} |
||||
|
paint={{ |
||||
|
"line-color": ["rgba", 255, 255, 255, 0.5], |
||||
|
"line-width": 2, |
||||
|
}} |
||||
|
/> |
||||
|
</Source> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export const PrecisionLayer = ({ |
||||
|
id, |
||||
|
filteredNodes, |
||||
|
isVisible, |
||||
|
}: PrecisionLayerProps): React.ReactNode => { |
||||
|
return ( |
||||
|
<SourcePrecisionCircles |
||||
|
data={generatePrecisionCircles(filteredNodes)} |
||||
|
id={id} |
||||
|
isVisible={isVisible} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,338 @@ |
|||||
|
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<Protobuf.Mesh.NeighborInfo, "neighbors"> & { |
||||
|
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<string, Pair>, |
||||
|
idToLngLat: Map<number, LngLat>, |
||||
|
): 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 generateNeighborLines( |
||||
|
neighborInfos: NeighborInfos[], |
||||
|
): FeatureCollection { |
||||
|
// Collect positions for all referenced nodes, discard pairs with missing positions
|
||||
|
const idToLngLat = new Map<number, LngLat>(); |
||||
|
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<string, Pair>(); |
||||
|
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) { |
||||
|
console.debug("Both directions", pair); |
||||
|
// both directions → two arcs
|
||||
|
const feat1 = makeFeature(pair.a, pair.b, aPos, bPos, pair.ab, true); |
||||
|
const feat2 = makeFeature(pair.b, pair.a, bPos, aPos, pair.ba, true); |
||||
|
|
||||
|
if (feat1) { |
||||
|
features.push(feat1); |
||||
|
} |
||||
|
if (feat2) { |
||||
|
features.push(feat2); |
||||
|
} |
||||
|
} else if (pair.ab) { |
||||
|
// only a->b, straight
|
||||
|
const feat = makeFeature(pair.a, pair.b, aPos, bPos, pair.ab, false); |
||||
|
if (feat) { |
||||
|
features.push(feat); |
||||
|
} |
||||
|
} else if (pair.ba) { |
||||
|
// only b->a, straight
|
||||
|
const feat = makeFeature(pair.b, pair.a, bPos, aPos, pair.ba, false); |
||||
|
if (feat) { |
||||
|
features.push(feat); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { type: "FeatureCollection", features }; |
||||
|
} |
||||
|
|
||||
|
export const SNRTooltip = ({ |
||||
|
pos, |
||||
|
snr, |
||||
|
from, |
||||
|
to, |
||||
|
}: Partial<SNRTooltipProps> = {}) => { |
||||
|
const { t } = useTranslation(); |
||||
|
|
||||
|
if (!pos) { |
||||
|
return undefined; |
||||
|
} |
||||
|
return ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
"absolute block p-2 px-3 text-sm bg-white dark:bg-slate-800 rounded-lg shadow", |
||||
|
"", |
||||
|
)} |
||||
|
style={{ left: `${pos.x + 5}px`, top: `${pos.y + 10}px` }} |
||||
|
aria-hidden={!pos} |
||||
|
> |
||||
|
<div> |
||||
|
<strong className="font-bold">{from ?? ""}</strong> |
||||
|
<span className="mx-1">⭢</span> |
||||
|
<strong className="font-bold">{to ?? ""}</strong> |
||||
|
</div> |
||||
|
<div> |
||||
|
SNR: <Mono>{snr?.toFixed?.(2) ?? t("unknown.shortName")}</Mono> dB |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
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 ( |
||||
|
<Source type="geojson" data={featureCollection}> |
||||
|
<Layer |
||||
|
id={id} |
||||
|
type="line" |
||||
|
paint={{ |
||||
|
"line-color": ["get", "color"], |
||||
|
"line-width": 5, |
||||
|
}} |
||||
|
/> |
||||
|
</Source> |
||||
|
); |
||||
|
}; |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
waypoints.forEach((waypoint) => { |
||||
|
const [lng, lat] = toLngLat({ |
||||
|
latitudeI: waypoint.latitudeI, |
||||
|
longitudeI: waypoint.longitudeI, |
||||
|
}); |
||||
|
rendered.push( |
||||
|
<NodeMarker |
||||
|
key={`waypoint-${waypoint.id}`} |
||||
|
id={waypoint.id} |
||||
|
lng={lng} |
||||
|
lat={lat} |
||||
|
label={String.fromCodePoint(waypoint.icon) ?? "📍"} |
||||
|
longLabel={waypoint.name} |
||||
|
avatarClassName="bg-amber-400 border-amber-500" |
||||
|
onClick={(_, e) => onMarkerClick(waypoint, e)} |
||||
|
/>, |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
if (popupState?.type === "waypoint") { |
||||
|
const [lng, lat] = toLngLat({ |
||||
|
latitudeI: popupState.waypoint.latitudeI, |
||||
|
longitudeI: popupState.waypoint.longitudeI, |
||||
|
}); |
||||
|
|
||||
|
rendered.push( |
||||
|
<PopupWrapper |
||||
|
key={`popup-waypoint-${popupState.waypoint.id}`} |
||||
|
lng={lng} |
||||
|
lat={lat} |
||||
|
offset={[0, 25]} |
||||
|
onClose={() => setPopupState(undefined)} |
||||
|
> |
||||
|
<WaypointDetail |
||||
|
waypoint={popupState.waypoint} |
||||
|
myNode={myNode} |
||||
|
onEdit={() => {}} |
||||
|
/> |
||||
|
</PopupWrapper>, |
||||
|
); |
||||
|
} |
||||
|
return rendered; |
||||
|
}; |
||||
@ -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 ( |
||||
|
<Marker |
||||
|
longitude={lng} |
||||
|
latitude={lat} |
||||
|
anchor="bottom" |
||||
|
style={{ |
||||
|
top: "20px", |
||||
|
pointerEvents: "none", |
||||
|
display: isVisible ? "" : "none", |
||||
|
}} |
||||
|
> |
||||
|
<TooltipProvider delayDuration={0}> |
||||
|
<Tooltip> |
||||
|
<TooltipTrigger asChild> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="will-change-transform cursor-pointer animate-fan-out" |
||||
|
style={style} |
||||
|
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })} |
||||
|
> |
||||
|
<Avatar |
||||
|
text={label} |
||||
|
className={cn( |
||||
|
"border-[1.5px] border-slate-600 shadow-m shadow-slate-600", |
||||
|
avatarClassName, |
||||
|
)} |
||||
|
showError={hasError} |
||||
|
showFavorite={isFavorite} |
||||
|
/> |
||||
|
</button> |
||||
|
</TooltipTrigger> |
||||
|
<TooltipPortal> |
||||
|
{tooltipLabel && ( // only show tooltip if there's a label
|
||||
|
<TooltipContent |
||||
|
className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs cursor-pointer" |
||||
|
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })} |
||||
|
> |
||||
|
{tooltipLabel} |
||||
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" /> |
||||
|
</TooltipContent> |
||||
|
)} |
||||
|
</TooltipPortal> |
||||
|
</Tooltip> |
||||
|
</TooltipProvider> |
||||
|
{longLabel && ( // only show label if there's a longLabel
|
||||
|
<button |
||||
|
type="button" |
||||
|
className="absolute top-16 left-1/2 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded bg-white/70 px-2 py-0.5 text-xs text-slate-900 backdrop-blur-xs cursor-pointer" |
||||
|
style={style} |
||||
|
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })} |
||||
|
> |
||||
|
{longLabel} |
||||
|
</button> |
||||
|
)} |
||||
|
</Marker> |
||||
|
); |
||||
|
}); |
||||
@ -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 ( |
||||
|
<Marker |
||||
|
longitude={lng} |
||||
|
latitude={lat} |
||||
|
style={{ left: 18, top: -18, display: isVisible ? "" : "none" }} |
||||
|
> |
||||
|
<button |
||||
|
onClick={(e) => onClick({ originalEvent: e.nativeEvent })} |
||||
|
className="rounded-full bg-blue-600 text-white text-xs px-2 py-0.5 shadow ring-1 ring-black/20 active:bg-red-800" |
||||
|
type={"button"} |
||||
|
> |
||||
|
+{count} |
||||
|
</button> |
||||
|
</Marker> |
||||
|
); |
||||
|
}; |
||||
@ -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 ( |
||||
|
<Popup |
||||
|
anchor="top" |
||||
|
longitude={lng} |
||||
|
latitude={lat} |
||||
|
onClose={onClose} |
||||
|
className="w-full" |
||||
|
style={{ |
||||
|
left: `${offset?.[0] ?? 0}px`, |
||||
|
top: `${(offset?.[1] ?? 0) + 22}px`, |
||||
|
}} |
||||
|
> |
||||
|
{children} |
||||
|
</Popup> |
||||
|
); |
||||
|
}); |
||||
@ -0,0 +1,170 @@ |
|||||
|
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, |
||||
|
Edit3Icon, |
||||
|
MapPinnedIcon, |
||||
|
MoveHorizontalIcon, |
||||
|
NavigationIcon, |
||||
|
RotateCwIcon, |
||||
|
UserLockIcon, |
||||
|
UserPenIcon, |
||||
|
} from "lucide-react"; |
||||
|
import { useTranslation } from "react-i18next"; |
||||
|
|
||||
|
interface WaypointDetailProps { |
||||
|
waypoint: WaypointWithMetadata; |
||||
|
myNode?: Protobuf.Mesh.NodeInfo; |
||||
|
onEdit: () => void; |
||||
|
} |
||||
|
|
||||
|
const RowElement: React.FC<{ |
||||
|
label: string; |
||||
|
value: React.ReactNode | string | number | undefined; |
||||
|
icon?: React.ReactNode; |
||||
|
}> = ({ label, value, icon }) => ( |
||||
|
<div className="flex justify-between"> |
||||
|
<span className="inline-flex items-center gap-2 text-slate-500 dark:text-slate-400"> |
||||
|
{icon} {label} |
||||
|
</span> |
||||
|
<span className="inline-flex items-center gap-1">{value}</span> |
||||
|
</div> |
||||
|
); |
||||
|
|
||||
|
export const WaypointDetail = ({ |
||||
|
waypoint, |
||||
|
myNode, |
||||
|
onEdit, |
||||
|
}: 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 ( |
||||
|
<div className="flex flex-col gap-2 px-1 text-sm"> |
||||
|
<div className="flex items-center my-1 justify-between"> |
||||
|
<div className="flex items-center gap-2 font-semibold text-slate-900 dark:text-slate-100"> |
||||
|
{String.fromCodePoint(waypoint.icon) ?? "📍"} |
||||
|
<span>{waypoint.name}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
{waypoint.description && ( |
||||
|
<div> |
||||
|
<span className="inline-flex items-center gap-1"> |
||||
|
{waypoint.description} |
||||
|
</span> |
||||
|
</div> |
||||
|
)} |
||||
|
<Separator /> |
||||
|
<div className="flex justify-between"> |
||||
|
<span className="inline-flex items-start gap-2 text-slate-500 dark:text-slate-400"> |
||||
|
<MapPinnedIcon size="20" /> |
||||
|
<span> |
||||
|
{t("waypointDetail.longitude")} {t("waypointDetail.latitude")} |
||||
|
</span> |
||||
|
</span> |
||||
|
<span className="text-right"> |
||||
|
{waypointLngLat[0]} {waypointLngLat[1]} |
||||
|
</span> |
||||
|
</div> |
||||
|
<RowElement |
||||
|
label={t("waypointDetail.createdDate")} |
||||
|
value={<TimeAgo timestamp={waypoint.metadata.created} />} |
||||
|
icon={<ClockPlusIcon size="14" />} |
||||
|
/> |
||||
|
{waypoint.metadata.updated && ( |
||||
|
<RowElement |
||||
|
label={t("waypointDetail.updated")} |
||||
|
value={<TimeAgo timestamp={waypoint.metadata.updated} />} |
||||
|
icon={<RotateCwIcon size="14" />} |
||||
|
/> |
||||
|
)} |
||||
|
{waypoint.expire !== 0 && ( |
||||
|
<RowElement |
||||
|
label={t("waypointDetail.expires")} |
||||
|
value={ |
||||
|
new Date( |
||||
|
waypoint.expire * 1000, |
||||
|
).toLocaleString() /* TODO: create "TimeAhead" component or refactor TimeAgo to allow future dates */ |
||||
|
} |
||||
|
icon={<ClockFadingIcon size="14" />} |
||||
|
/> |
||||
|
)} |
||||
|
{distance && ( |
||||
|
<RowElement |
||||
|
label={t("waypointDetail.distance")} |
||||
|
value={`${Math.round(distance)} ${distance === 1 ? t("unit.meter.one") : t("unit.meter.plural")}`} |
||||
|
icon={<MoveHorizontalIcon size="14" />} |
||||
|
/> |
||||
|
)} |
||||
|
{bearing && ( |
||||
|
<RowElement |
||||
|
label={t("waypointDetail.bearing")} |
||||
|
value={ |
||||
|
<> |
||||
|
<NavigationIcon |
||||
|
size="16" |
||||
|
aria-hidden |
||||
|
className="shrink-0 origin-center transition-transform" |
||||
|
style={{ transform: `rotate(${bearing - 45}deg)` }} |
||||
|
/> |
||||
|
{Math.round(bearing)}° |
||||
|
</> |
||||
|
} |
||||
|
icon={<CompassIcon size="14" />} |
||||
|
/> |
||||
|
)} |
||||
|
<RowElement |
||||
|
label={t("waypointDetail.createdBy")} |
||||
|
value={ |
||||
|
getNode(waypoint.metadata.from)?.user?.longName ?? |
||||
|
t("unknown.longName") |
||||
|
} |
||||
|
icon={<UserPenIcon size="14" />} |
||||
|
/> |
||||
|
{waypoint.lockedTo ? ( |
||||
|
<RowElement |
||||
|
label={t("waypointDetail.lockedTo")} |
||||
|
value={ |
||||
|
getNode(waypoint.lockedTo)?.user?.longName ?? t("unknown.longName") |
||||
|
} |
||||
|
icon={<UserLockIcon size="14" />} |
||||
|
/> |
||||
|
) : ( |
||||
|
<div className="flex justify-end "> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={onEdit} |
||||
|
className="inline-flex items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700" |
||||
|
> |
||||
|
<Edit3Icon className="h-4 w-4" /> |
||||
|
{t("waypointDetail.edit")} |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -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 ( |
||||
|
<Checkbox |
||||
|
checked={checked} |
||||
|
onChange={onChange} |
||||
|
className={cn("flex items-center gap-2", className)} |
||||
|
> |
||||
|
<span className="dark:text-slate-200">{label}</span> |
||||
|
</Checkbox> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export function MapLayerTool({ |
||||
|
visibilityState, |
||||
|
setVisibilityState, |
||||
|
}: MapLayerToolProps): ReactNode { |
||||
|
const { t } = useTranslation("map"); |
||||
|
|
||||
|
return ( |
||||
|
<Popover> |
||||
|
<PopoverTrigger asChild> |
||||
|
<button |
||||
|
type="button" |
||||
|
className={cn( |
||||
|
"rounded align-center", |
||||
|
"w-[29px] px-1 py-1 shadow-l outline-[2px] outline-stone-600/20", |
||||
|
"bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 ", |
||||
|
"text-slate-600 hover:text-slate-700 active:bg-slate-300", |
||||
|
"dark:text-slate-600 hover:dark:text-slate-700", |
||||
|
)} |
||||
|
aria-label={t("mapMenu.layersAria")} |
||||
|
> |
||||
|
<LayersIcon className="w-[21px]" /> |
||||
|
</button> |
||||
|
</PopoverTrigger> |
||||
|
<PopoverContent |
||||
|
className={cn("dark:text-slate-300 flex flex-col space-y-2 py-4")} |
||||
|
side={"bottom"} |
||||
|
align="end" |
||||
|
sideOffset={7} |
||||
|
> |
||||
|
<CheckboxItem |
||||
|
label={t("layerTool.nodeMarkers")} |
||||
|
checked={visibilityState.nodeMarkers} |
||||
|
onChange={(checked) => { |
||||
|
setVisibilityState({ ...visibilityState, nodeMarkers: checked }); |
||||
|
}} |
||||
|
/> |
||||
|
<CheckboxItem |
||||
|
label={t("layerTool.waypoints")} |
||||
|
checked={visibilityState.waypoints} |
||||
|
onChange={(checked) => { |
||||
|
setVisibilityState({ ...visibilityState, waypoints: checked }); |
||||
|
}} |
||||
|
/> |
||||
|
<CheckboxItem |
||||
|
label={t("layerTool.directNeighbors")} |
||||
|
checked={visibilityState.directNeighbors} |
||||
|
onChange={(checked) => { |
||||
|
setVisibilityState({ |
||||
|
...visibilityState, |
||||
|
directNeighbors: checked, |
||||
|
}); |
||||
|
}} |
||||
|
/> |
||||
|
<CheckboxItem |
||||
|
label={t("layerTool.remoteNeighbors")} |
||||
|
checked={visibilityState.remoteNeighbors} |
||||
|
onChange={(checked) => { |
||||
|
setVisibilityState({ |
||||
|
...visibilityState, |
||||
|
remoteNeighbors: checked, |
||||
|
}); |
||||
|
}} |
||||
|
/> |
||||
|
<CheckboxItem |
||||
|
label={t("layerTool.positionPrecision")} |
||||
|
checked={visibilityState.positionPrecision} |
||||
|
onChange={(checked) => { |
||||
|
setVisibilityState({ |
||||
|
...visibilityState, |
||||
|
positionPrecision: checked, |
||||
|
}); |
||||
|
}} |
||||
|
/> |
||||
|
{/*<CheckboxItem |
||||
|
key="traceroutes" |
||||
|
label={t("layerTool.traceroutes")} |
||||
|
checked={visibilityState.traceroutes} |
||||
|
onChange={(checked) => { |
||||
|
setVisibilityState({ ...visibilityState, traceroutes: checked }); |
||||
|
}} |
||||
|
/>*/} |
||||
|
</PopoverContent> |
||||
|
</Popover> |
||||
|
); |
||||
|
} |
||||
@ -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<ClusterKey, Protobuf.Mesh.NodeInfo[]> { |
||||
|
const map = new Map<ClusterKey, Protobuf.Mesh.NodeInfo[]>(); |
||||
|
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<PxOffset> { |
||||
|
const R = 10 + 5 * size; // radius in pixels
|
||||
|
const base = hashToAngle(key); |
||||
|
const out: Array<PxOffset> = []; |
||||
|
|
||||
|
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; |
||||
|
} |
||||
@ -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 }; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
}; |
||||
Loading…
Reference in new issue