Map improvements - node and neighbor display (#850)
* Improves map node and neighbor display Updates the map page to enhance the visualization of nodes, waypoints, and neighbor connections. Adds a map layer tool for toggling the visibility of direct/remote neighbors and position precision indicators. Introduces clustering to improve map readability when multiple nodes share the exact same location. Adds SNR lines to visually represent network connections between nodes Co-Authored-By: jamon <jamon@users.noreply.github.com> * Clean up * Update packages/web/src/components/generic/TimeAgo.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Improve dark mode and expires field * Review fixes Co-Authored-By: Dan Ditomaso <dan.ditomaso@gmail.com> --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: jamon <jamon@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Dan Ditomaso <dan.ditomaso@gmail.com>
This commit is contained in:
co-authored by
jamon
Copilot
Dan Ditomaso
philon-
parent
8cc451546d
commit
36443fd838
@@ -1,7 +1,9 @@
|
||||
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MapGl, {
|
||||
AttributionControl,
|
||||
type MapLayerMouseEvent,
|
||||
type MapRef,
|
||||
NavigationControl,
|
||||
ScaleControl,
|
||||
@@ -10,10 +12,21 @@ import MapGl, {
|
||||
interface MapProps {
|
||||
children?: React.ReactNode;
|
||||
onLoad?: (map: MapRef) => void;
|
||||
onMouseMove?: (event: MapLayerMouseEvent) => void;
|
||||
onClick?: (event: MapLayerMouseEvent) => void;
|
||||
interactiveLayerIds?: string[];
|
||||
}
|
||||
|
||||
export const BaseMap = ({ children, onLoad }: MapProps) => {
|
||||
export const BaseMap = ({
|
||||
children,
|
||||
onLoad,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
interactiveLayerIds,
|
||||
}: MapProps) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useTranslation("map");
|
||||
|
||||
const darkMode = theme === "dark";
|
||||
const mapRef = useRef<MapRef | null>(null);
|
||||
|
||||
@@ -24,6 +37,27 @@ export const BaseMap = ({ children, onLoad }: MapProps) => {
|
||||
}
|
||||
}, [onLoad]);
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return {
|
||||
"GeolocateControl.FindMyLocation": t(
|
||||
"maplibre.GeolocateControl.FindMyLocation",
|
||||
),
|
||||
"NavigationControl.ZoomIn": t("maplibre.NavigationControl.ZoomIn"),
|
||||
"NavigationControl.ZoomOut": t("maplibre.NavigationControl.ZoomOut"),
|
||||
"ScaleControl.Meters": t("unit.meter.suffix"),
|
||||
"ScaleControl.Kilometers": t("unit.kilometer.suffix"),
|
||||
"CooperativeGesturesHandler.WindowsHelpText": t(
|
||||
"maplibre.CooperativeGesturesHandler.WindowsHelpText",
|
||||
),
|
||||
"CooperativeGesturesHandler.MacHelpText": t(
|
||||
"maplibre.CooperativeGesturesHandler.MacHelpText",
|
||||
),
|
||||
"CooperativeGesturesHandler.MobileHelpText": t(
|
||||
"maplibre.CooperativeGesturesHandler.MobileHelpText",
|
||||
),
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<MapGl
|
||||
ref={mapRef}
|
||||
@@ -39,6 +73,10 @@ export const BaseMap = ({ children, onLoad }: MapProps) => {
|
||||
longitude: 0,
|
||||
}}
|
||||
style={{ filter: darkMode ? "brightness(0.9)" : undefined }}
|
||||
locale={locale}
|
||||
interactiveLayerIds={interactiveLayerIds}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onClick}
|
||||
>
|
||||
<AttributionControl
|
||||
style={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
-6
@@ -65,14 +65,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
className="text-green-600 mb-1.5"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label={t("node_detail_public_key_enabled_aria_label")}
|
||||
aria-label={t("nodeDetail.publicKeyEnabled.label")}
|
||||
/>
|
||||
) : (
|
||||
<LockOpenIcon
|
||||
className="text-yellow-500 mb-1.5"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label={t("node_detail_no_public_key_aria_label")}
|
||||
aria-label={t("nodeDetail.noPublicKey.label")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -158,7 +158,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
: node.hopsAway}
|
||||
</div>
|
||||
<div>
|
||||
{node.hopsAway === 1 ? t("unit.hops.one") : t("unit.hop.plural")}
|
||||
{node.hopsAway === 1 ? t("unit.hop.one") : t("unit.hop.plural")}
|
||||
</div>
|
||||
</div>
|
||||
{node.position?.altitude && (
|
||||
@@ -166,7 +166,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
<MountainSnow
|
||||
size={15}
|
||||
className="ml-2 mr-1"
|
||||
aria-label={t("node_detail_elevation_aria_label")}
|
||||
aria-label={t("nodeDetail.elevation.label")}
|
||||
/>
|
||||
<div>
|
||||
{formatQuantity(node.position?.altitude, {
|
||||
@@ -181,7 +181,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
<div className="flex mt-2">
|
||||
{!!node.deviceMetrics?.channelUtilization && (
|
||||
<div className="grow">
|
||||
<div>{t("nodeDetail.channelUtilization")}</div>
|
||||
<div>{t("channelUtilization.short")}</div>
|
||||
<Mono>
|
||||
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
|
||||
</Mono>
|
||||
@@ -189,7 +189,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
)}
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="grow">
|
||||
<div>{t("nodeDetail.airTxUtilization")}</div>
|
||||
<div>{t("airtimeUtilization.short")}</div>
|
||||
<Mono className="text-gray-500">
|
||||
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
|
||||
</Mono>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -44,12 +44,17 @@ export const Avatar = ({
|
||||
`relative flex items-center justify-center rounded-full font-semibold
|
||||
`,
|
||||
sizes[size],
|
||||
"bg-[rgb(var(--bg-r),var(--bg-g),var(--bg-b))]", // allow override with className
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
|
||||
color: textColor,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--bg-r": bgColor.r,
|
||||
"--bg-g": bgColor.g,
|
||||
"--bg-b": bgColor.b,
|
||||
color: textColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{showFavorite ? (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
|
||||
@@ -26,10 +26,13 @@ const TooltipContent = React.forwardRef<
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
const TooltipPortal = TooltipPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TooltipPortal,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ export const TimeAgo = ({
|
||||
|
||||
useEffect(() => {
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
let timerId: number;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const update = () => {
|
||||
const { value, unit } = getRelativeTimeParts(date);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -22,6 +22,15 @@ export type ValidModuleConfigType = Exclude<
|
||||
undefined
|
||||
>;
|
||||
|
||||
export type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
|
||||
metadata: {
|
||||
channel: number; // Channel on which the waypoint was received
|
||||
created: Date; // Timestamp when the waypoint was received
|
||||
updated?: Date; // Timestamp when the waypoint was last updated
|
||||
from: number; // Node number of the device that sent the waypoint
|
||||
};
|
||||
};
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
status: Types.DeviceStatusEnum;
|
||||
@@ -39,7 +48,8 @@ export interface Device {
|
||||
>;
|
||||
connection?: MeshDevice;
|
||||
activeNode: number;
|
||||
waypoints: Protobuf.Mesh.Waypoint[];
|
||||
waypoints: WaypointWithMetadata[];
|
||||
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
|
||||
pendingSettingsChanges: boolean;
|
||||
messageDraft: string;
|
||||
unreadCounts: Map<number, number>;
|
||||
@@ -99,7 +109,12 @@ export interface Device {
|
||||
setActiveNode: (node: number) => void;
|
||||
setPendingSettingsChanges: (state: boolean) => void;
|
||||
addChannel: (channel: Protobuf.Channel.Channel) => void;
|
||||
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => void;
|
||||
addWaypoint: (
|
||||
waypoint: Protobuf.Mesh.Waypoint,
|
||||
channel: Types.ChannelNumber,
|
||||
from: number,
|
||||
rxTime: Date,
|
||||
) => void;
|
||||
addConnection: (connection: MeshDevice) => void;
|
||||
addTraceRoute: (
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||
@@ -120,6 +135,11 @@ export interface Device {
|
||||
getClientNotification: (
|
||||
index: number,
|
||||
) => Protobuf.Mesh.ClientNotification | undefined;
|
||||
addNeighborInfo: (
|
||||
nodeNum: number,
|
||||
neighborInfo: Protobuf.Mesh.NeighborInfo,
|
||||
) => void;
|
||||
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined;
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
@@ -156,6 +176,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
connection: undefined,
|
||||
activeNode: 0,
|
||||
waypoints: [],
|
||||
neighborInfo: new Map(),
|
||||
dialog: {
|
||||
import: false,
|
||||
QR: false,
|
||||
@@ -543,7 +564,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => {
|
||||
addWaypoint: (waypoint, channel, from, rxTime) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
@@ -552,9 +573,19 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
(wp) => wp.id === waypoint.id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
device.waypoints[index] = waypoint;
|
||||
const created =
|
||||
device.waypoints[index]?.metadata.created ?? new Date();
|
||||
const updatedWaypoint = {
|
||||
...waypoint,
|
||||
metadata: { created, updated: rxTime, from, channel },
|
||||
};
|
||||
|
||||
device.waypoints[index] = updatedWaypoint;
|
||||
} else {
|
||||
device.waypoints.push(waypoint);
|
||||
device.waypoints.push({
|
||||
...waypoint,
|
||||
metadata: { created: rxTime, from, channel },
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -720,6 +751,30 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
}
|
||||
return device.clientNotifications[index];
|
||||
},
|
||||
addNeighborInfo: (
|
||||
nodeId: number,
|
||||
neighborInfo: Protobuf.Mesh.NeighborInfo,
|
||||
) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace any existing neighbor info for this nodeId
|
||||
device.neighborInfo.set(nodeId, neighborInfo);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
getNeighborInfo: (nodeNum: number) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
return device.neighborInfo.get(nodeNum);
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
useDeviceStore,
|
||||
type ValidConfigType,
|
||||
type ValidModuleConfigType,
|
||||
type WaypointWithMetadata,
|
||||
} from "@core/stores/deviceStore";
|
||||
export {
|
||||
MessageState,
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface NodeDB {
|
||||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||
includeSelf?: boolean,
|
||||
) => Protobuf.Mesh.NodeInfo[];
|
||||
getMyNode: () => Protobuf.Mesh.NodeInfo;
|
||||
getMyNode: () => Protobuf.Mesh.NodeInfo | undefined;
|
||||
|
||||
getNodeError: (nodeNum: number) => NodeError | undefined;
|
||||
hasNodeError: (nodeNum: number) => boolean;
|
||||
@@ -378,13 +378,12 @@ function nodeDBFactory(
|
||||
if (!nodeDB) {
|
||||
throw new Error(`No nodeDB found (id: ${id})`);
|
||||
}
|
||||
if (!nodeDB.myNodeNum) {
|
||||
throw new Error(`No myNodeNum set for nodeDB with id: ${id}`);
|
||||
if (nodeDB.myNodeNum) {
|
||||
return (
|
||||
nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema)
|
||||
);
|
||||
}
|
||||
return (
|
||||
nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema)
|
||||
);
|
||||
},
|
||||
|
||||
getNodeError: (nodeNum) => {
|
||||
|
||||
@@ -136,16 +136,16 @@ describe("NodeDB store", () => {
|
||||
expect(db.getNodeError(10)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getMyNode throws before setNodeNum; works after", async () => {
|
||||
it("getMyNode returns undefined before setNodeNum; works after", async () => {
|
||||
const { useNodeDBStore } = await freshStore();
|
||||
const db = useNodeDBStore.getState().addNodeDB(1);
|
||||
db.addNode(makeNode(123));
|
||||
|
||||
expect(() => db.getMyNode()).toThrow();
|
||||
expect(db.getMyNode()).toBeUndefined();
|
||||
db.setNodeNum(123);
|
||||
|
||||
const me = db.getMyNode();
|
||||
expect(me.num).toBe(123);
|
||||
expect(me?.num).toBe(123);
|
||||
});
|
||||
|
||||
it("setNodeNum merges with existing DB with same myNodeNum", async () => {
|
||||
|
||||
@@ -51,8 +51,8 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onWaypointPacket.subscribe((waypoint) => {
|
||||
const { data } = waypoint;
|
||||
device.addWaypoint(data);
|
||||
const { data, channel, from, rxTime } = waypoint;
|
||||
device.addWaypoint(data, channel, from, rxTime);
|
||||
});
|
||||
|
||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||
@@ -127,6 +127,10 @@ export const subscribeAll = (
|
||||
},
|
||||
);
|
||||
|
||||
connection.events.onNeighborInfoPacket.subscribe((neighborInfo) => {
|
||||
device.addNeighborInfo(neighborInfo.from, neighborInfo.data);
|
||||
});
|
||||
|
||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||
if (routingPacket.data.variant.case === "errorReason") {
|
||||
switch (routingPacket.data.variant.value) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -58,5 +58,6 @@ i18next
|
||||
"messages",
|
||||
"nodes",
|
||||
"ui",
|
||||
"map",
|
||||
],
|
||||
});
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fan-out {
|
||||
transform: translate(var(--dx), var(--dy));
|
||||
transition: transform 200ms ease-out; /* expand AND collapse */
|
||||
}
|
||||
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,64 +1,76 @@
|
||||
import {
|
||||
defaultVisibilityState,
|
||||
MapLayerTool,
|
||||
type VisibilityState,
|
||||
} from "@app/components/PageComponents/Map/Tools/MapLayerTool.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import {
|
||||
type FilterState,
|
||||
useFilterNode,
|
||||
} from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { BaseMap } from "@components/Map.tsx";
|
||||
import { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx";
|
||||
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
|
||||
import {
|
||||
SNRLayer,
|
||||
SNRTooltip,
|
||||
type SNRTooltipProps,
|
||||
} from "@components/PageComponents/Map/Layers/SNRLayer.tsx";
|
||||
import { WaypointLayer } from "@components/PageComponents/Map/Layers/WaypointLayer.tsx";
|
||||
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useDevice, useNodeDB } from "@core/stores";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting.ts";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { hasPos, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { bbox, lineString } from "@turf/turf";
|
||||
import { FunnelIcon, MapPinIcon } from "lucide-react";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { FunnelIcon, LocateFixedIcon } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
|
||||
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
|
||||
import { Avatar } from "../../components/UI/Avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre";
|
||||
|
||||
const NODEDB_DEBOUNCE_MS = 250;
|
||||
|
||||
type NodePosition = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const convertToLatLng = (position?: {
|
||||
latitudeI?: number;
|
||||
longitudeI?: number;
|
||||
}): NodePosition => ({
|
||||
latitude: (position?.latitudeI ?? 0) / 1e7,
|
||||
longitude: (position?.longitudeI ?? 0) / 1e7,
|
||||
});
|
||||
|
||||
const MapPage = () => {
|
||||
const { waypoints } = useDevice();
|
||||
const { nodes: validNodes, hasNodeError } = useNodeDB(
|
||||
const { t } = useTranslation("map");
|
||||
const { getNode } = useNodeDB();
|
||||
const { nodes: validNodes, myNode } = useNodeDB(
|
||||
(db) => ({
|
||||
// only nodes with a position
|
||||
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo =>
|
||||
Boolean(n.position?.latitudeI),
|
||||
),
|
||||
hasNodeError: db.hasNodeError,
|
||||
// include the Map reference so error badges update when nodeErrors changes
|
||||
myNode: db.getMyNode(),
|
||||
|
||||
// References to cause re-render on change
|
||||
_errorsRef: db.nodeErrors,
|
||||
_nodeNumRef: db.myNodeNum,
|
||||
}),
|
||||
{ debounce: NODEDB_DEBOUNCE_MS },
|
||||
);
|
||||
|
||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||
const { default: mapRef } = useMap();
|
||||
const { focusLngLat, fitToNodes } = useMapFitting(mapRef);
|
||||
|
||||
const { default: map } = useMap();
|
||||
const hasFitBoundsOnce = useRef(false);
|
||||
const [snrHover, setSnrHover] = useState<SNRTooltipProps>();
|
||||
const [expandedCluster, setExpandedCluster] = useState<string | undefined>();
|
||||
const [popupState, setPopupState] = useState<PopupState | undefined>();
|
||||
|
||||
const [selectedNode, setSelectedNode] =
|
||||
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
|
||||
() => defaultVisibilityState,
|
||||
);
|
||||
|
||||
// Filters
|
||||
const [filterState, setFilterState] = useState<FilterState>(
|
||||
() => defaultFilterValues,
|
||||
);
|
||||
@@ -69,143 +81,187 @@ const MapPage = () => {
|
||||
[validNodes, deferredFilterState, nodeFilter],
|
||||
);
|
||||
|
||||
const hasFitBoundsOnce = useRef(false);
|
||||
// Map fitting
|
||||
const getMapBounds = useCallback(() => {
|
||||
if (!hasFitBoundsOnce.current) {
|
||||
fitToNodes(validNodes);
|
||||
hasFitBoundsOnce.current = true;
|
||||
}
|
||||
}, [fitToNodes, validNodes]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
||||
event?.originalEvent?.stopPropagation();
|
||||
|
||||
setSelectedNode(node);
|
||||
|
||||
if (map) {
|
||||
const position = convertToLatLng(node.position);
|
||||
map.easeTo({
|
||||
center: [position.longitude, position.latitude],
|
||||
zoom: map?.getZoom(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[map],
|
||||
// SNR lines
|
||||
const snrLayerElementId = useId();
|
||||
const snrLayerElement = useMemo(
|
||||
() => (
|
||||
<SNRLayer
|
||||
id={snrLayerElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
myNode={myNode}
|
||||
visibilityState={visibilityState}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, myNode, visibilityState, snrLayerElementId],
|
||||
);
|
||||
|
||||
// Get the bounds of the map based on the nodes furtherest away from center
|
||||
const getMapBounds = useCallback(() => {
|
||||
if (hasFitBoundsOnce.current || !map || validNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const onMouseMove = useCallback(
|
||||
(event: MapLayerMouseEvent) => {
|
||||
const {
|
||||
features,
|
||||
point: { x, y },
|
||||
} = event;
|
||||
const hoveredFeature = features?.[0];
|
||||
|
||||
if (validNodes.length === 1 && validNodes[0]) {
|
||||
map.easeTo({
|
||||
zoom: map.getZoom(),
|
||||
center: [
|
||||
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
|
||||
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hoveredFeature) {
|
||||
const { from, to, snr } = hoveredFeature.properties;
|
||||
|
||||
const line = lineString(
|
||||
validNodes.map((n) => [
|
||||
(n.position?.latitudeI ?? 0) / 1e7,
|
||||
(n.position?.longitudeI ?? 0) / 1e7,
|
||||
]),
|
||||
);
|
||||
const bounds = bbox(line);
|
||||
const center = map.cameraForBounds(
|
||||
[
|
||||
[bounds[1], bounds[0]],
|
||||
[bounds[3], bounds[2]],
|
||||
],
|
||||
{ padding: { top: 10, bottom: 10, left: 10, right: 10 } },
|
||||
);
|
||||
if (center) {
|
||||
map.easeTo(center);
|
||||
}
|
||||
hasFitBoundsOnce.current = true;
|
||||
}, [map, validNodes]);
|
||||
const fromLong =
|
||||
getNode(from)?.user?.longName ??
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(from).slice(-4).toUpperCase(),
|
||||
});
|
||||
|
||||
// Generate all markers
|
||||
const markers = useMemo(
|
||||
() =>
|
||||
filteredNodes.map((node) => {
|
||||
const position = convertToLatLng(node.position);
|
||||
return (
|
||||
<Marker
|
||||
key={`marker-${node.num}`}
|
||||
longitude={position.longitude}
|
||||
latitude={position.latitude}
|
||||
anchor="bottom"
|
||||
onClick={(e) => handleMarkerClick(node, e)}
|
||||
>
|
||||
<Avatar
|
||||
text={node.user?.shortName?.toString() ?? node.num.toString()}
|
||||
className="border-[1.5px] border-slate-600 shadow-m shadow-slate-600"
|
||||
showError={hasNodeError(node.num)}
|
||||
showFavorite={node.isFavorite}
|
||||
/>
|
||||
</Marker>
|
||||
);
|
||||
}),
|
||||
[filteredNodes, handleMarkerClick, hasNodeError],
|
||||
const toLong =
|
||||
getNode(to)?.user?.longName ??
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(to).slice(-4).toUpperCase(),
|
||||
});
|
||||
|
||||
setSnrHover({ pos: { x, y }, snr, from: fromLong, to: toLong });
|
||||
} else {
|
||||
setSnrHover(undefined);
|
||||
}
|
||||
},
|
||||
[getNode, t],
|
||||
);
|
||||
|
||||
// Node markers & clusters
|
||||
const onMapBackgroundClick = useCallback(() => {
|
||||
setExpandedCluster(undefined);
|
||||
}, []);
|
||||
|
||||
const markerElements = useMemo(
|
||||
() => (
|
||||
<NodesLayer
|
||||
mapRef={mapRef}
|
||||
filteredNodes={filteredNodes}
|
||||
myNode={myNode}
|
||||
expandedCluster={expandedCluster}
|
||||
setExpandedCluster={setExpandedCluster}
|
||||
popupState={popupState}
|
||||
setPopupState={setPopupState}
|
||||
isVisible={visibilityState.nodeMarkers}
|
||||
/>
|
||||
),
|
||||
[
|
||||
filteredNodes,
|
||||
expandedCluster,
|
||||
mapRef,
|
||||
myNode,
|
||||
popupState,
|
||||
visibilityState.nodeMarkers,
|
||||
],
|
||||
);
|
||||
|
||||
// Precision circles
|
||||
const precisionCirclesElementId = useId();
|
||||
const precisionCirclesElement = useMemo(
|
||||
() => (
|
||||
<PrecisionLayer
|
||||
id={precisionCirclesElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
isVisible={visibilityState.positionPrecision}
|
||||
/>
|
||||
),
|
||||
[
|
||||
filteredNodes,
|
||||
visibilityState.positionPrecision,
|
||||
precisionCirclesElementId,
|
||||
],
|
||||
);
|
||||
|
||||
// Waypoints
|
||||
const waypointLayerElement = useMemo(
|
||||
() => (
|
||||
<WaypointLayer
|
||||
mapRef={mapRef}
|
||||
myNode={myNode}
|
||||
isVisible={visibilityState.waypoints}
|
||||
popupState={popupState}
|
||||
setPopupState={setPopupState}
|
||||
/>
|
||||
),
|
||||
[mapRef, myNode, visibilityState.waypoints, popupState],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
|
||||
<BaseMap onLoad={getMapBounds}>
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
longitude={(wp.longitudeI ?? 0) / 1e7}
|
||||
latitude={(wp.latitudeI ?? 0) / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div>
|
||||
<MapPinIcon size={16} />
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{markers}
|
||||
{selectedNode &&
|
||||
(() => {
|
||||
const position = convertToLatLng(selectedNode.position);
|
||||
return (
|
||||
<Popup
|
||||
key={selectedNode.num}
|
||||
anchor="top"
|
||||
longitude={position.longitude}
|
||||
latitude={position.latitude}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
className="w-full"
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</BaseMap>
|
||||
<BaseMap
|
||||
onLoad={getMapBounds}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onMapBackgroundClick}
|
||||
interactiveLayerIds={[snrLayerElementId]}
|
||||
>
|
||||
{markerElements}
|
||||
{snrLayerElement}
|
||||
{precisionCirclesElement}
|
||||
{waypointLayerElement}
|
||||
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 12,
|
||||
},
|
||||
popoverTriggerClassName: cn(
|
||||
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
||||
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
|
||||
isFilterDirty(filterState)
|
||||
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
||||
: "",
|
||||
),
|
||||
triggerIcon: <FunnelIcon className="w-5" />,
|
||||
showTextSearch: true,
|
||||
}}
|
||||
/>
|
||||
{snrHover && (
|
||||
<SNRTooltip
|
||||
pos={snrHover.pos}
|
||||
snr={snrHover.snr}
|
||||
from={snrHover.from}
|
||||
to={snrHover.to}
|
||||
/>
|
||||
)}
|
||||
</BaseMap>
|
||||
<div className="flex flex-col space-y-1 fixed top-35 right-2.5">
|
||||
{myNode && hasPos(myNode?.position) && (
|
||||
<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",
|
||||
"dark:text-slate-600 hover:dark:text-slate-700",
|
||||
)}
|
||||
aria-label={t("mapMenu.locateAria")}
|
||||
onClick={() => focusLngLat(toLngLat(myNode.position))}
|
||||
>
|
||||
{" "}
|
||||
<LocateFixedIcon className="w-[21px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 7,
|
||||
},
|
||||
popoverTriggerClassName: cn(
|
||||
"w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
||||
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 dark:active:bg-stone-300",
|
||||
isFilterDirty(filterState)
|
||||
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
||||
: "",
|
||||
),
|
||||
triggerIcon: <FunnelIcon className="w-[21px]" />,
|
||||
showTextSearch: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<MapLayerTool
|
||||
visibilityState={visibilityState}
|
||||
setVisibilityState={setVisibilityState}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user