Browse Source
* Improves map node and neighbor display Updates the map page to enhance the visualization of nodes, waypoints, and neighbor connections. Adds a map layer tool for toggling the visibility of direct/remote neighbors and position precision indicators. Introduces clustering to improve map readability when multiple nodes share the exact same location. Adds SNR lines to visually represent network connections between nodes Co-Authored-By: jamon <[email protected]> * Clean up * Update packages/web/src/components/generic/TimeAgo.tsx Co-authored-by: Copilot <[email protected]> * Update packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx Co-authored-by: Copilot <[email protected]> * Improve dark mode and expires field * Review fixes Co-Authored-By: Dan Ditomaso <[email protected]> --------- Co-authored-by: philon- <[email protected]> Co-authored-by: jamon <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Dan Ditomaso <[email protected]>pull/863/head
committed by
GitHub
31 changed files with 1877 additions and 182 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
|
|||
for (const [i, node] of nodes.entries()) { |
|||
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,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<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 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<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) { |
|||
// 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<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; |
|||
} |
|||
|
|||
for (const waypoint of waypoints) { |
|||
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,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 ( |
|||
<article |
|||
aria-labelledby={`wp-${waypoint.id}-title`} |
|||
className="flex flex-col gap-2 px-1 text-sm dark:text-slate-900" |
|||
> |
|||
<header className="flex items-center my-1 justify-between"> |
|||
<h3 |
|||
id={`wp-${waypoint.id}-title`} |
|||
className="flex items-center gap-2 font-semibold text-slate-900" |
|||
> |
|||
<span aria-hidden>{String.fromCodePoint(waypoint.icon) ?? "📍"}</span> |
|||
<span>{waypoint.name}</span> |
|||
</h3> |
|||
</header> |
|||
|
|||
{waypoint.description && ( |
|||
<p className="inline-flex items-center gap-1">{waypoint.description}</p> |
|||
)} |
|||
|
|||
<Separator className="dark:bg-slate-200" role="separator" /> |
|||
|
|||
<section aria-label={t("waypointDetail.details")}> |
|||
<dl className="space-y-1.5"> |
|||
{/* Coordinates */} |
|||
<div className="flex flex-wrap items-start gap-x-3"> |
|||
<dt className="inline-flex items-top gap-2 text-slate-500 min-w-0"> |
|||
<MapPinnedIcon size={14} aria-hidden className="mt-1" /> |
|||
<span className="truncate"> |
|||
{t("waypointDetail.longitude")} |
|||
<br /> |
|||
{t("waypointDetail.latitude")} |
|||
</span> |
|||
</dt> |
|||
<dd className="ms-auto text-right"> |
|||
<data value={waypointLngLat[0]}>{waypointLngLat[0]}</data> |
|||
<br /> |
|||
<data value={waypointLngLat[1]}>{waypointLngLat[1]}</data> |
|||
</dd> |
|||
</div> |
|||
|
|||
{/* Created */} |
|||
<div className="flex flex-wrap items-start gap-x-3"> |
|||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0"> |
|||
<ClockPlusIcon size={14} aria-hidden /> |
|||
<span className="truncate"> |
|||
{t("waypointDetail.createdDate")} |
|||
</span> |
|||
</dt> |
|||
<dd className="ms-auto text-right"> |
|||
<time |
|||
dateTime={new Date(waypoint.metadata.created).toISOString()} |
|||
> |
|||
<TimeAgo timestamp={waypoint.metadata.created} /> |
|||
</time> |
|||
</dd> |
|||
</div> |
|||
|
|||
{/* Updated */} |
|||
{waypoint.metadata.updated && ( |
|||
<div className="flex flex-wrap items-start gap-x-3"> |
|||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0"> |
|||
<RotateCwIcon size={14} aria-hidden /> |
|||
<span className="truncate">{t("waypointDetail.updated")}</span> |
|||
</dt> |
|||
<dd className="ms-auto text-right"> |
|||
<time |
|||
dateTime={new Date(waypoint.metadata.updated).toISOString()} |
|||
> |
|||
<TimeAgo timestamp={waypoint.metadata.updated} /> |
|||
</time> |
|||
</dd> |
|||
</div> |
|||
)} |
|||
|
|||
{/* Expires */} |
|||
{waypoint.expire !== 0 && ( |
|||
<div className="flex flex-wrap items-start gap-x-3"> |
|||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0"> |
|||
<ClockFadingIcon size={14} aria-hidden /> |
|||
<span className="truncate">{t("waypointDetail.expires")}</span> |
|||
</dt> |
|||
<dd className="ms-auto text-right"> |
|||
<time dateTime={new Date(waypoint.expire * 1000).toISOString()}> |
|||
<TimeAgo timestamp={waypoint.expire * 1000} /> |
|||
</time> |
|||
</dd> |
|||
</div> |
|||
)} |
|||
|
|||
{/* Distance */} |
|||
{distance != null && ( |
|||
<div className="flex flex-wrap items-start gap-x-3"> |
|||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0"> |
|||
<MoveHorizontalIcon size={14} aria-hidden /> |
|||
<span className="truncate">{t("waypointDetail.distance")}</span> |
|||
</dt> |
|||
<dd className="ms-auto text-right"> |
|||
<data value={Math.round(distance)}> |
|||
{Math.round(distance)}{" "} |
|||
{distance === 1 |
|||
? t("unit.meter.one") |
|||
: t("unit.meter.plural")} |
|||
</data> |
|||
</dd> |
|||
</div> |
|||
)} |
|||
|
|||
{/* Bearing */} |
|||
{bearing != null && ( |
|||
<div className="flex flex-wrap items-start gap-x-3"> |
|||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0"> |
|||
<CompassIcon size={14} aria-hidden /> |
|||
<span className="truncate">{t("waypointDetail.bearing")}</span> |
|||
</dt> |
|||
<dd className="ms-auto text-right inline-flex items-center "> |
|||
<NavigationIcon |
|||
size={16} |
|||
aria-hidden |
|||
className="shrink-0 origin-center transition-transform mr-2" |
|||
style={{ transform: `rotate(${bearing - 45}deg)` }} |
|||
/> |
|||
<data value={Math.round(bearing)}>{Math.round(bearing)}</data> |
|||
<span aria-hidden>{t("unit.degree.suffix")}</span> |
|||
</dd> |
|||
</div> |
|||
)} |
|||
|
|||
{/* Locked To */} |
|||
{waypoint.lockedTo && ( |
|||
<div className="flex flex-wrap items-start gap-x-3"> |
|||
<dt className="inline-flex items-center gap-2 text-slate-500 min-w-0"> |
|||
<UserLockIcon size={14} aria-hidden /> |
|||
<span className="truncate">{t("waypointDetail.lockedTo")}</span> |
|||
</dt> |
|||
<dd className="ms-auto text-right"> |
|||
{getNode(waypoint.lockedTo)?.user?.longName ?? |
|||
t("unknown.longName")} |
|||
</dd> |
|||
</div> |
|||
)} |
|||
</dl> |
|||
</section> |
|||
</article> |
|||
); |
|||
}; |
|||
@ -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