Browse Source

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 <[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
Jeremy Gallant 9 months ago
committed by GitHub
parent
commit
36443fd838
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      packages/web/package.json
  2. 4
      packages/web/public/i18n/locales/en/common.json
  3. 38
      packages/web/public/i18n/locales/en/map.json
  4. 6
      packages/web/public/i18n/locales/en/ui.json
  5. 42
      packages/web/src/components/Map.tsx
  6. 174
      packages/web/src/components/PageComponents/Map/Layers/NodesLayer.tsx
  7. 137
      packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx
  8. 341
      packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx
  9. 94
      packages/web/src/components/PageComponents/Map/Layers/WaypointLayer.tsx
  10. 106
      packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx
  11. 33
      packages/web/src/components/PageComponents/Map/Markers/StackBadge.tsx
  12. 12
      packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx
  13. 38
      packages/web/src/components/PageComponents/Map/Popups/PopupWrapper.tsx
  14. 191
      packages/web/src/components/PageComponents/Map/Popups/WaypointDetail.tsx
  15. 143
      packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx
  16. 56
      packages/web/src/components/PageComponents/Map/cluster.ts
  17. 13
      packages/web/src/components/UI/Avatar.tsx
  18. 3
      packages/web/src/components/UI/Tooltip.tsx
  19. 2
      packages/web/src/components/generic/TimeAgo.tsx
  20. 50
      packages/web/src/core/hooks/useMapFitting.ts
  21. 65
      packages/web/src/core/stores/deviceStore/index.ts
  22. 1
      packages/web/src/core/stores/index.ts
  23. 13
      packages/web/src/core/stores/nodeDBStore/index.ts
  24. 6
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx
  25. 8
      packages/web/src/core/subscriptions.ts
  26. 87
      packages/web/src/core/utils/geo.ts
  27. 31
      packages/web/src/core/utils/signalColor.ts
  28. 1
      packages/web/src/i18n-config.ts
  29. 6
      packages/web/src/index.css
  30. 354
      packages/web/src/pages/Map/index.tsx
  31. 3
      pnpm-lock.yaml

1
packages/web/package.json

@ -92,6 +92,7 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.1.0",
"@types/geojson": "^7946.0.16",
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",

4
packages/web/public/i18n/locales/en/common.json

@ -47,6 +47,7 @@
"megahertz": "MHz",
"raw": "raw",
"meter": { "one": "Meter", "plural": "Meters", "suffix": "m" },
"kilometer": { "one": "Kilometer", "plural": "Kilometers", "suffix": "km" },
"minute": { "one": "Minute", "plural": "Minutes" },
"hour": { "one": "Hour", "plural": "Hours" },
"millisecond": {
@ -65,7 +66,8 @@
"year": { "one": "Year", "plural": "Years" },
"snr": "SNR",
"volt": { "one": "Volt", "plural": "Volts", "suffix": "V" },
"record": { "one": "Records", "plural": "Records" }
"record": { "one": "Records", "plural": "Records" },
"degree": { "one": "Degree", "plural": "Degrees", "suffix": "°" }
},
"security": {
"0bit": "Empty",

38
packages/web/public/i18n/locales/en/map.json

@ -0,0 +1,38 @@
{
"maplibre": {
"GeolocateControl.FindMyLocation": "Find my location",
"NavigationControl.ZoomIn": "Zoom in",
"NavigationControl.ZoomOut": "Zoom out",
"CooperativeGesturesHandler.WindowsHelpText": "Use Ctrl + scroll to zoom the map",
"CooperativeGesturesHandler.MacHelpText": "Use ⌘ + scroll to zoom the map",
"CooperativeGesturesHandler.MobileHelpText": "Use two fingers to move the map"
},
"layerTool": {
"nodeMarkers": "Show nodes",
"directNeighbors": "Show direct connections",
"remoteNeighbors": "Show remote connections",
"positionPrecision": "Show position precision",
"traceroutes": "Show traceroutes",
"waypoints": "Show waypoints"
},
"mapMenu": {
"locateAria": "Locate my node",
"layersAria": "Change map style"
},
"waypointDetail": {
"edit": "Edit",
"description": "Description:",
"createdBy": "Edited by:",
"createdDate": "Created:",
"updated": "Updated:",
"expires": "Expires:",
"distance": "Distance:",
"bearing": "Absolute bearing:",
"lockedTo": "Locked by:",
"latitude": "Latitude:",
"longitude": "Longitude:"
},
"myNode": {
"tooltip": "This device"
}
}

6
packages/web/public/i18n/locales/en/ui.json

@ -140,7 +140,8 @@
"placeholder": "Meshtastic 1234"
},
"airtimeUtilization": {
"label": "Airtime Utilization (%)"
"label": "Airtime Utilization (%)",
"short": "Airtime Util. (%)"
},
"batteryLevel": {
"label": "Battery level (%)",
@ -151,7 +152,8 @@
"title": "Voltage"
},
"channelUtilization": {
"label": "Channel Utilization (%)"
"label": "Channel Utilization (%)",
"short": "Channel Util. (%)"
},
"hops": {
"direct": "Direct",

42
packages/web/src/components/Map.tsx

@ -1,7 +1,9 @@
import { useTheme } from "@core/hooks/useTheme.ts";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import MapGl, {
AttributionControl,
type MapLayerMouseEvent,
type MapRef,
NavigationControl,
ScaleControl,
@ -10,10 +12,21 @@ import MapGl, {
interface MapProps {
children?: React.ReactNode;
onLoad?: (map: MapRef) => void;
onMouseMove?: (event: MapLayerMouseEvent) => void;
onClick?: (event: MapLayerMouseEvent) => void;
interactiveLayerIds?: string[];
}
export const BaseMap = ({ children, onLoad }: MapProps) => {
export const BaseMap = ({
children,
onLoad,
onClick,
onMouseMove,
interactiveLayerIds,
}: MapProps) => {
const { theme } = useTheme();
const { t } = useTranslation("map");
const darkMode = theme === "dark";
const mapRef = useRef<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={{

174
packages/web/src/components/PageComponents/Map/Layers/NodesLayer.tsx

@ -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;
};

137
packages/web/src/components/PageComponents/Map/Layers/PrecisionLayer.tsx

@ -0,0 +1,137 @@
import { getColorFromText, isLightColor } from "@app/core/utils/color";
import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { circle } from "@turf/turf";
import type { Feature, FeatureCollection, Polygon } from "geojson";
import { Layer, Source } from "react-map-gl/maplibre";
export interface PrecisionLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
isVisible: boolean;
}
type CircleProps = {
r: number;
g: number;
b: number;
a?: number;
sortKey: number;
};
export function generatePrecisionCircles(
filteredNodes: Protobuf.Mesh.NodeInfo[],
): FeatureCollection {
const unique = new Map<
string,
{
lng: number;
lat: number;
radiusM: number;
r: number;
g: number;
b: number;
a: number;
}
>();
for (const node of filteredNodes) {
if (
node.position?.precisionBits === undefined ||
node.position.precisionBits === 0
) {
continue;
}
const [lng, lat] = toLngLat(node.position);
const radiusM = precisionBitsToMeters(node.position?.precisionBits ?? 0);
const safeText =
node.user?.shortName ??
numberToHexUnpadded(node.num).slice(-4).toUpperCase();
const color = getColorFromText(safeText);
const isLight = isLightColor(color);
const key = `${lat},${lng}:${radiusM}`;
if (!unique.has(key)) {
unique.set(key, {
lng,
lat,
radiusM,
r: color.r,
g: color.g,
b: color.b,
a: isLight ? 0.3 : 0.2, // light colors need more alpha to be as visible
});
}
}
const items = Array.from(unique.values()).sort(
(a, b) => a.radiusM - b.radiusM,
);
const features: Feature<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}
/>
);
};

341
packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx

@ -0,0 +1,341 @@
import { Mono } from "@app/components/generic/Mono";
import { cn } from "@app/core/utils/cn";
import { getSignalColor } from "@app/core/utils/signalColor";
import type { VisibilityState } from "@components/PageComponents/Map/Tools/MapLayerTool";
import { useDevice } from "@core/stores";
import {
distanceMeters,
hasPos,
type LngLat,
lngLatToMercator,
mercatorToLngLat,
toLngLat,
} from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import type { Feature, FeatureCollection } from "geojson";
import { useTranslation } from "react-i18next";
import { Layer, Source } from "react-map-gl/maplibre";
const ARC_SEGMENTS = 32;
const ARC_OFFSET = 0.01; // 1% of distance
const MIN_LEN = 1e-3; // meters
export interface SNRLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
myNode: Protobuf.Mesh.NodeInfo | undefined;
visibilityState: VisibilityState;
}
export interface SNRTooltipProps {
pos: { x: number; y: number };
snr: number;
from: string;
to: string;
}
type NeighborPlus = Protobuf.Mesh.Neighbor & {
num: number | undefined;
position: Protobuf.Mesh.Position | undefined;
};
type NeighborInfoPlus = Omit<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>
);
};

94
packages/web/src/components/PageComponents/Map/Layers/WaypointLayer.tsx

@ -0,0 +1,94 @@
import { NodeMarker } from "@components/PageComponents/Map/Markers/NodeMarker.tsx";
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { WaypointDetail } from "@components/PageComponents/Map/Popups/WaypointDetail.tsx";
import { useMapFitting } from "@core/hooks/useMapFitting";
import { useDevice, type WaypointWithMetadata } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import type { MapRef } from "react-map-gl/maplibre";
export interface WaypointLayerProps {
mapRef: MapRef | undefined;
myNode: Protobuf.Mesh.NodeInfo | undefined;
isVisible: boolean;
popupState: PopupState | undefined;
setPopupState: (state: PopupState | undefined) => void;
}
import { toLngLat } from "@core/utils/geo.ts";
export const WaypointLayer = ({
mapRef,
myNode,
isVisible,
popupState,
setPopupState,
}: WaypointLayerProps): React.ReactNode[] => {
const { waypoints } = useDevice();
const { focusLngLat } = useMapFitting(mapRef);
const onMarkerClick = useCallback(
(waypoint: WaypointWithMetadata, e: { originalEvent: MouseEvent }) => {
e.originalEvent?.stopPropagation();
setPopupState({ type: "waypoint", waypoint });
if (waypoint.longitudeI && waypoint.latitudeI) {
focusLngLat(
toLngLat({
longitudeI: waypoint.longitudeI,
latitudeI: waypoint.latitudeI,
}),
);
}
},
[focusLngLat, setPopupState],
);
const rendered: React.ReactNode[] = [];
if (!isVisible) {
return rendered;
}
for (const waypoint of waypoints) {
const [lng, lat] = toLngLat({
latitudeI: waypoint.latitudeI,
longitudeI: waypoint.longitudeI,
});
rendered.push(
<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;
};

106
packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx

@ -0,0 +1,106 @@
import { cn } from "@app/core/utils/cn";
import type { PxOffset } from "@components/PageComponents/Map/cluster.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { memo } from "react";
import { Marker } from "react-map-gl/maplibre";
export const NodeMarker = memo(function NodeMarker({
id,
lng,
lat,
label,
longLabel,
tooltipLabel,
hasError,
isFavorite,
offset,
avatarClassName,
isVisible = true,
onClick,
}: {
id: number;
lng: number;
lat: number;
label: string;
longLabel?: string;
tooltipLabel?: string;
hasError?: boolean;
isFavorite?: boolean;
offset?: PxOffset;
avatarClassName?: string;
isVisible?: boolean;
onClick: (id: number, e: { originalEvent: MouseEvent }) => void;
}) {
const [dx, dy] = offset ?? [0, 0];
const style = {
"--dx": `${dx}px`,
"--dy": `${dy}px`,
pointerEvents: "auto",
} as React.CSSProperties;
return (
<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>
);
});

33
packages/web/src/components/PageComponents/Map/Markers/StackBadge.tsx

@ -0,0 +1,33 @@
import { Marker } from "react-map-gl/maplibre";
type StackBadgeProps = {
lng: number;
lat: number;
count: number; // n = size-1
isVisible?: boolean;
onClick: (e: { originalEvent: MouseEvent }) => void;
};
export const StackBadge = ({
lng,
lat,
count,
isVisible = true,
onClick,
}: StackBadgeProps) => {
return (
<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>
);
};

12
packages/web/src/components/PageComponents/Map/NodeDetail.tsx → packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx

@ -65,14 +65,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
className="text-green-600 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_public_key_enabled_aria_label")}
aria-label={t("nodeDetail.publicKeyEnabled.label")}
/>
) : (
<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>

38
packages/web/src/components/PageComponents/Map/Popups/PopupWrapper.tsx

@ -0,0 +1,38 @@
import type { PxOffset } from "@components/PageComponents/Map/cluster.ts";
import type { WaypointWithMetadata } from "@core/stores";
import { memo } from "react";
import { Popup } from "react-map-gl/maplibre";
export type PopupState =
| { type: "node"; num: number; offset: PxOffset }
| { type: "waypoint"; waypoint: WaypointWithMetadata };
export const PopupWrapper = memo(function SelectedNodePopup({
lng,
lat,
offset,
onClose,
children,
}: {
lng: number;
lat: number;
offset?: PxOffset;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<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>
);
});

191
packages/web/src/components/PageComponents/Map/Popups/WaypointDetail.tsx

@ -0,0 +1,191 @@
import { TimeAgo } from "@components/generic/TimeAgo";
import { Separator } from "@components/UI/Separator.tsx";
import type { WaypointWithMetadata } from "@core/stores";
import { useNodeDB } from "@core/stores";
import {
bearingDegrees,
distanceMeters,
hasPos,
toLngLat,
} from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import {
ClockFadingIcon,
ClockPlusIcon,
CompassIcon,
MapPinnedIcon,
MoveHorizontalIcon,
NavigationIcon,
RotateCwIcon,
UserLockIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface WaypointDetailProps {
waypoint: WaypointWithMetadata;
myNode?: Protobuf.Mesh.NodeInfo;
onEdit: () => void;
}
export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
const { t } = useTranslation("map");
const { getNode } = useNodeDB();
const waypointLngLat = toLngLat({
latitudeI: waypoint.latitudeI,
longitudeI: waypoint.longitudeI,
});
const distance = hasPos(myNode?.position)
? distanceMeters(toLngLat(myNode?.position), waypointLngLat)
: undefined;
const bearing = hasPos(myNode?.position)
? bearingDegrees(toLngLat(myNode?.position), waypointLngLat)
: undefined;
return (
<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>
);
};

143
packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx

@ -0,0 +1,143 @@
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@components/UI/Popover.tsx";
import { cn } from "@core/utils/cn.ts";
import { LayersIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
export interface VisibilityState {
nodeMarkers: boolean;
directNeighbors: boolean;
remoteNeighbors: boolean;
positionPrecision: boolean;
traceroutes: boolean;
waypoints: boolean;
}
export const defaultVisibilityState: VisibilityState = {
nodeMarkers: true,
directNeighbors: false,
remoteNeighbors: false,
positionPrecision: false,
traceroutes: false,
waypoints: true,
};
interface MapLayerToolProps {
visibilityState: VisibilityState;
setVisibilityState: (state: VisibilityState) => void;
}
interface CheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
className?: string;
}
const CheckboxItem = ({
label,
checked,
onChange,
className,
}: CheckboxProps) => {
return (
<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>
);
}

56
packages/web/src/components/PageComponents/Map/cluster.ts

@ -0,0 +1,56 @@
import type { Protobuf } from "@meshtastic/core";
export type ClusterKey = string;
export type PxOffset = [number, number];
export function makeClusterKey(pos: Protobuf.Mesh.Position): ClusterKey {
return `${pos.latitudeI},${pos.longitudeI}`;
}
export function groupNodesByIdenticalCoords(
nodes: Protobuf.Mesh.NodeInfo[],
): Map<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;
}

13
packages/web/src/components/UI/Avatar.tsx

@ -44,12 +44,17 @@ export const Avatar = ({
`relative flex items-center justify-center rounded-full font-semibold
`,
sizes[size],
"bg-[rgb(var(--bg-r),var(--bg-g),var(--bg-b))]", // allow override with className
className,
)}
style={{
backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
color: textColor,
}}
style={
{
"--bg-r": bgColor.r,
"--bg-g": bgColor.g,
"--bg-b": bgColor.b,
color: textColor,
} as React.CSSProperties
}
>
{showFavorite ? (
<TooltipProvider delayDuration={300}>

3
packages/web/src/components/UI/Tooltip.tsx

@ -26,10 +26,13 @@ const TooltipContent = React.forwardRef<
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
const TooltipPortal = TooltipPrimitive.Portal;
export {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
TooltipPortal,
};

2
packages/web/src/components/generic/TimeAgo.tsx

@ -90,7 +90,7 @@ export const TimeAgo = ({
useEffect(() => {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
let timerId: number;
let timerId: ReturnType<typeof setTimeout>;
const update = () => {
const { value, unit } = getRelativeTimeParts(date);

50
packages/web/src/core/hooks/useMapFitting.ts

@ -0,0 +1,50 @@
import { boundsFromLngLat, type LngLat, toLngLat } from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import type { MapRef } from "react-map-gl/maplibre";
export function useMapFitting(map: MapRef | undefined) {
const focusLngLat = useCallback(
(position: LngLat) => {
if (!map) {
return;
}
const [lng, lat] = position;
map.easeTo({
center: [lng, lat],
zoom: map.getZoom(),
});
},
[map],
);
const fitToNodes = useCallback(
(nodes: Protobuf.Mesh.NodeInfo[]) => {
if (!map || nodes.length === 0) {
return;
}
if (nodes.length === 1 && nodes[0]) {
return focusLngLat(toLngLat(nodes[0].position));
}
// Build [lng, lat] coords, then let boundsFromLngLat do the turf dance
const coords = nodes.map((n) => toLngLat(n.position));
const bounds = boundsFromLngLat(coords);
if (!bounds) {
return;
}
const center = map.cameraForBounds(bounds, {
padding: { top: 10, bottom: 10, left: 10, right: 10 },
});
if (center) {
map.easeTo(center);
}
},
[map, focusLngLat],
);
return { focusLngLat, fitToNodes };
}

65
packages/web/src/core/stores/deviceStore/index.ts

@ -22,6 +22,15 @@ export type ValidModuleConfigType = Exclude<
undefined
>;
export type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: {
channel: number; // Channel on which the waypoint was received
created: Date; // Timestamp when the waypoint was received
updated?: Date; // Timestamp when the waypoint was last updated
from: number; // Node number of the device that sent the waypoint
};
};
export interface Device {
id: number;
status: Types.DeviceStatusEnum;
@ -39,7 +48,8 @@ export interface Device {
>;
connection?: MeshDevice;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
waypoints: WaypointWithMetadata[];
neighborInfo: Map<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);
},
});
}),
);

1
packages/web/src/core/stores/index.ts

@ -16,6 +16,7 @@ export {
useDeviceStore,
type ValidConfigType,
type ValidModuleConfigType,
type WaypointWithMetadata,
} from "@core/stores/deviceStore";
export {
MessageState,

13
packages/web/src/core/stores/nodeDBStore/index.ts

@ -41,7 +41,7 @@ export interface NodeDB {
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
includeSelf?: boolean,
) => Protobuf.Mesh.NodeInfo[];
getMyNode: () => Protobuf.Mesh.NodeInfo;
getMyNode: () => Protobuf.Mesh.NodeInfo | undefined;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean;
@ -378,13 +378,12 @@ function nodeDBFactory(
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
if (!nodeDB.myNodeNum) {
throw new Error(`No myNodeNum set for nodeDB with id: ${id}`);
if (nodeDB.myNodeNum) {
return (
nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
create(Protobuf.Mesh.NodeInfoSchema)
);
}
return (
nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
create(Protobuf.Mesh.NodeInfoSchema)
);
},
getNodeError: (nodeNum) => {

6
packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx

@ -136,16 +136,16 @@ describe("NodeDB store", () => {
expect(db.getNodeError(10)).toBeUndefined();
});
it("getMyNode throws before setNodeNum; works after", async () => {
it("getMyNode returns undefined before setNodeNum; works after", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.addNode(makeNode(123));
expect(() => db.getMyNode()).toThrow();
expect(db.getMyNode()).toBeUndefined();
db.setNodeNum(123);
const me = db.getMyNode();
expect(me.num).toBe(123);
expect(me?.num).toBe(123);
});
it("setNodeNum merges with existing DB with same myNodeNum", async () => {

8
packages/web/src/core/subscriptions.ts

@ -51,8 +51,8 @@ export const subscribeAll = (
});
connection.events.onWaypointPacket.subscribe((waypoint) => {
const { data } = waypoint;
device.addWaypoint(data);
const { data, channel, from, rxTime } = waypoint;
device.addWaypoint(data, channel, from, rxTime);
});
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
@ -127,6 +127,10 @@ export const subscribeAll = (
},
);
connection.events.onNeighborInfoPacket.subscribe((neighborInfo) => {
device.addNeighborInfo(neighborInfo.from, neighborInfo.data);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {

87
packages/web/src/core/utils/geo.ts

@ -0,0 +1,87 @@
import { bbox, lineString } from "@turf/turf";
export type LngLat = [number, number];
export type Mercator = [number, number];
export type Bounds = [[number, number], [number, number]];
const INT_DEG = 1e7;
const EARTH_RADIUS = 6378137;
export const toLngLat = (position?: {
latitudeI?: number;
longitudeI?: number;
}): LngLat => [
(position?.longitudeI ?? 0) / INT_DEG,
(position?.latitudeI ?? 0) / INT_DEG,
];
export const hasPos = (position?: {
latitudeI?: number;
longitudeI?: number;
}) =>
Number.isFinite(position?.latitudeI) &&
Number.isFinite(position?.longitudeI) &&
!(position?.latitudeI === 0 && position?.longitudeI === 0);
export const boundsFromLngLat = (coords: LngLat[]): Bounds | undefined => {
if (coords.length === 0) {
return undefined;
}
const turfCoords = coords.map(([lng, lat]) => [lat, lng]);
const [minLat, minLng, maxLat, maxLng] = bbox(lineString(turfCoords));
return [
[minLng, minLat],
[maxLng, maxLat],
];
};
const deg2rad = (d: number) => (d * Math.PI) / 180;
const rad2deg = (r: number) => (r * 180) / Math.PI;
export function lngLatToMercator([lng, lat]: LngLat): Mercator {
return [
EARTH_RADIUS * deg2rad(lng),
EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + deg2rad(lat) / 2)),
];
}
export function mercatorToLngLat([x, y]: Mercator): LngLat {
return [
rad2deg(x / EARTH_RADIUS),
rad2deg(2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2),
];
}
export function distanceMeters([lng1, lat1]: LngLat, [lng2, lat2]: LngLat) {
const phi1 = deg2rad(lat1),
phi2 = deg2rad(lat2);
const x = deg2rad(lng2 - lng1) * Math.cos((phi1 + phi2) * 0.5);
const y = phi2 - phi1;
return EARTH_RADIUS * Math.hypot(x, y);
}
export function precisionBitsToMeters(precisionBits: number): number {
const M_PER_DEG_EQ = (2 * Math.PI * EARTH_RADIUS) / 360; // ≈ 111_319.490793 m/deg
const stepInt = 2 ** (32 - precisionBits);
const stepDegrees = stepInt / INT_DEG;
return Math.round(0.5 * stepDegrees * M_PER_DEG_EQ);
}
export function bearingDegrees(from: LngLat, to: LngLat): number {
const [lambda1deg, phi1deg] = from;
const [lambda2deg, phi2deg] = to;
const phi1 = deg2rad(phi1deg);
const phi2 = deg2rad(phi2deg);
const deltaLambda = deg2rad(lambda2deg - lambda1deg);
const y = Math.sin(deltaLambda) * Math.cos(phi2);
const x =
Math.cos(phi1) * Math.sin(phi2) -
Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
return (rad2deg(Math.atan2(y, x)) + 360) % 360;
}

31
packages/web/src/core/utils/signalColor.ts

@ -0,0 +1,31 @@
export const SNR_THRESHOLD = {
GOOD: -7,
FAIR: -15,
};
export const RSSI_THRESHOLD = {
GOOD: -115,
FAIR: -126,
};
export const LINE_COLOR = {
GOOD: "#00ff00",
FAIR: "#ffe600",
BAD: "#f7931a",
};
export const getSignalColor = (snr: number, rssi?: number): string => {
if (
snr > SNR_THRESHOLD.GOOD &&
(rssi == null || rssi > RSSI_THRESHOLD.GOOD)
) {
return LINE_COLOR.GOOD;
}
if (
snr > SNR_THRESHOLD.FAIR &&
(rssi == null || rssi > RSSI_THRESHOLD.FAIR)
) {
return LINE_COLOR.FAIR;
}
return LINE_COLOR.BAD;
};

1
packages/web/src/i18n-config.ts

@ -58,5 +58,6 @@ i18next
"messages",
"nodes",
"ui",
"map",
],
});

6
packages/web/src/index.css

@ -74,6 +74,12 @@
}
}
.animate-fan-out {
transform: translate(var(--dx), var(--dy));
transition: transform 200ms ease-out; /* expand AND collapse */
}
html {
overflow: hidden;
}

354
packages/web/src/pages/Map/index.tsx

@ -1,64 +1,76 @@
import {
defaultVisibilityState,
MapLayerTool,
type VisibilityState,
} from "@app/components/PageComponents/Map/Tools/MapLayerTool.tsx";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { BaseMap } from "@components/Map.tsx";
import { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx";
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
import {
SNRLayer,
SNRTooltip,
type SNRTooltipProps,
} from "@components/PageComponents/Map/Layers/SNRLayer.tsx";
import { WaypointLayer } from "@components/PageComponents/Map/Layers/WaypointLayer.tsx";
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { useDevice, useNodeDB } from "@core/stores";
import { useMapFitting } from "@core/hooks/useMapFitting.ts";
import { useNodeDB } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { hasPos, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
import { bbox, lineString } from "@turf/turf";
import { FunnelIcon, MapPinIcon } from "lucide-react";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { FunnelIcon, LocateFixedIcon } from "lucide-react";
import {
useCallback,
useDeferredValue,
useId,
useMemo,
useRef,
useState,
} from "react";
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
import { Avatar } from "../../components/UI/Avatar.tsx";
import { useTranslation } from "react-i18next";
import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre";
const NODEDB_DEBOUNCE_MS = 250;
type NodePosition = {
latitude: number;
longitude: number;
};
const convertToLatLng = (position?: {
latitudeI?: number;
longitudeI?: number;
}): NodePosition => ({
latitude: (position?.latitudeI ?? 0) / 1e7,
longitude: (position?.longitudeI ?? 0) / 1e7,
});
const MapPage = () => {
const { waypoints } = useDevice();
const { nodes: validNodes, hasNodeError } = useNodeDB(
const { t } = useTranslation("map");
const { getNode } = useNodeDB();
const { nodes: validNodes, myNode } = useNodeDB(
(db) => ({
// only nodes with a position
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo =>
Boolean(n.position?.latitudeI),
),
hasNodeError: db.hasNodeError,
// include the Map reference so error badges update when nodeErrors changes
myNode: db.getMyNode(),
// References to cause re-render on change
_errorsRef: db.nodeErrors,
_nodeNumRef: db.myNodeNum,
}),
{ debounce: NODEDB_DEBOUNCE_MS },
);
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const { default: mapRef } = useMap();
const { focusLngLat, fitToNodes } = useMapFitting(mapRef);
const { default: map } = useMap();
const hasFitBoundsOnce = useRef(false);
const [snrHover, setSnrHover] = useState<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]);
// SNR lines
const snrLayerElementId = useId();
const snrLayerElement = useMemo(
() => (
<SNRLayer
id={snrLayerElementId}
filteredNodes={filteredNodes}
myNode={myNode}
visibilityState={visibilityState}
/>
),
[filteredNodes, myNode, visibilityState, snrLayerElementId],
);
const onMouseMove = useCallback(
(event: MapLayerMouseEvent) => {
const {
features,
point: { x, y },
} = event;
const hoveredFeature = features?.[0];
if (hoveredFeature) {
const { from, to, snr } = hoveredFeature.properties;
const handleMarkerClick = useCallback(
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
event?.originalEvent?.stopPropagation();
const fromLong =
getNode(from)?.user?.longName ??
t("fallbackName", {
last4: numberToHexUnpadded(from).slice(-4).toUpperCase(),
});
setSelectedNode(node);
const toLong =
getNode(to)?.user?.longName ??
t("fallbackName", {
last4: numberToHexUnpadded(to).slice(-4).toUpperCase(),
});
if (map) {
const position = convertToLatLng(node.position);
map.easeTo({
center: [position.longitude, position.latitude],
zoom: map?.getZoom(),
});
setSnrHover({ pos: { x, y }, snr, from: fromLong, to: toLong });
} else {
setSnrHover(undefined);
}
},
[map],
[getNode, t],
);
// Get the bounds of the map based on the nodes furtherest away from center
const getMapBounds = useCallback(() => {
if (hasFitBoundsOnce.current || !map || validNodes.length === 0) {
return;
}
// Node markers & clusters
const onMapBackgroundClick = useCallback(() => {
setExpandedCluster(undefined);
}, []);
if (validNodes.length === 1 && validNodes[0]) {
map.easeTo({
zoom: map.getZoom(),
center: [
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
],
});
return;
}
const markerElements = useMemo(
() => (
<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,
],
);
const line = lineString(
validNodes.map((n) => [
(n.position?.latitudeI ?? 0) / 1e7,
(n.position?.longitudeI ?? 0) / 1e7,
]),
);
const bounds = bbox(line);
const center = map.cameraForBounds(
[
[bounds[1], bounds[0]],
[bounds[3], bounds[2]],
],
{ padding: { top: 10, bottom: 10, left: 10, right: 10 } },
);
if (center) {
map.easeTo(center);
}
hasFitBoundsOnce.current = true;
}, [map, validNodes]);
// Generate all markers
const markers = useMemo(
() =>
filteredNodes.map((node) => {
const position = convertToLatLng(node.position);
return (
<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],
// 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
onLoad={getMapBounds}
onMouseMove={onMouseMove}
onClick={onMapBackgroundClick}
interactiveLayerIds={[snrLayerElementId]}
>
{markerElements}
{snrLayerElement}
{precisionCirclesElement}
{waypointLayerElement}
{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: 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,
}}
/>
<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>
);
};

3
pnpm-lock.yaml

@ -280,6 +280,9 @@ importers:
'@types/chrome':
specifier: ^0.1.0
version: 0.1.3
'@types/geojson':
specifier: ^7946.0.16
version: 7946.0.16
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6

Loading…
Cancel
Save