Skip to content

Commit ce71f22

Browse files
authored
Merge pull request #580 from philon-/feature/map-filtering
Add filters to node map
2 parents 1f3f763 + 03e516e commit ce71f22

File tree

6 files changed

+405
-4
lines changed

6 files changed

+405
-4
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@radix-ui/react-scroll-area": "^1.2.3",
5252
"@radix-ui/react-select": "^2.1.6",
5353
"@radix-ui/react-separator": "^1.1.2",
54+
"@radix-ui/react-slider": "^1.3.2",
5455
"@radix-ui/react-switch": "^1.1.3",
5556
"@radix-ui/react-tabs": "^1.1.3",
5657
"@radix-ui/react-toast": "^1.2.6",

src/components/UI/Checkbox/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useId } from "react";
1+
import { useState, useEffect, useId } from "react";
22
import { Check } from "lucide-react";
33
import { Label } from "@components/UI/Label.tsx";
44
import { cn } from "@core/utils/cn.ts";
@@ -32,6 +32,11 @@ export function Checkbox({
3232

3333
const [isChecked, setIsChecked] = useState(checked || false);
3434

35+
// Make sure setIsChecked state updates with checked
36+
useEffect(() => {
37+
setIsChecked(checked || false);
38+
}, [checked]);
39+
3540
const handleToggle = () => {
3641
if (disabled) return;
3742

src/components/UI/Slider.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useId, useState } from "react";
2+
import * as SliderPrimitive from "@radix-ui/react-slider";
3+
import { cn } from "@core/utils/cn.ts";
4+
5+
export interface SliderProps {
6+
value: number[];
7+
step?: number;
8+
min?: number;
9+
max: number;
10+
onValueChange?: (value: number[]) => void;
11+
onValueCommit?: (value: number[]) => void;
12+
disabled?: boolean;
13+
className?: string;
14+
trackClassName?: string;
15+
rangeClassName?: string;
16+
thumbClassName?: string;
17+
}
18+
19+
export function Slider({
20+
value,
21+
step = 1,
22+
min = 0,
23+
max,
24+
onValueChange,
25+
onValueCommit,
26+
disabled = false,
27+
className,
28+
trackClassName,
29+
rangeClassName,
30+
thumbClassName,
31+
...props
32+
}: SliderProps) {
33+
const [internalValue, setInternalValue] = useState<number[]>(value);
34+
const isControlled = value !== undefined;
35+
const currentValue = isControlled ? value! : internalValue;
36+
const id = useId();
37+
38+
const handleValueChange = (newValue: number[]) => {
39+
if (!isControlled) setInternalValue(newValue);
40+
onValueChange?.(newValue);
41+
};
42+
43+
const handleValueCommit = (newValue: number[]) => {
44+
onValueCommit?.(newValue);
45+
};
46+
47+
return (
48+
<SliderPrimitive.Root
49+
className={cn(
50+
"relative flex items-center select-none touch-none",
51+
className,
52+
)}
53+
value={currentValue}
54+
step={step}
55+
min={min}
56+
max={max}
57+
disabled={disabled}
58+
onValueChange={handleValueChange}
59+
onValueCommit={handleValueCommit}
60+
{...props}
61+
>
62+
<SliderPrimitive.Track
63+
className={cn(
64+
"relative h-2 flex-1 rounded-full bg-slate-200",
65+
trackClassName,
66+
)}
67+
>
68+
<SliderPrimitive.Range
69+
className={cn(
70+
"absolute h-full rounded-full bg-blue-500",
71+
rangeClassName,
72+
)}
73+
/>
74+
</SliderPrimitive.Track>
75+
{currentValue.map((_, i) => (
76+
<SliderPrimitive.Thumb
77+
key={`${id}-thumb-${i}`}
78+
className={cn(
79+
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md",
80+
thumbClassName,
81+
)}
82+
aria-label={`Thumb ${i + 1}`}
83+
/>
84+
))}
85+
</SliderPrimitive.Root>
86+
);
87+
}

src/core/hooks/useNodeFilters.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { useCallback, useMemo, useState } from "react";
2+
import type { Protobuf } from "@meshtastic/core";
3+
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
4+
5+
interface BooleanFilter {
6+
key: string;
7+
label: string;
8+
type: "boolean";
9+
predicate: (node: Node, value: boolean) => boolean;
10+
}
11+
12+
interface RangeFilter {
13+
key: string;
14+
label: string;
15+
type: "range";
16+
bounds: [number, number];
17+
predicate: (node: Node, value: [number, number]) => boolean;
18+
}
19+
20+
interface SearchFilter {
21+
key: string;
22+
label: string;
23+
type: "search";
24+
predicate: (node: Node, value: string) => boolean;
25+
}
26+
27+
export type FilterConfig = BooleanFilter | RangeFilter | SearchFilter;
28+
29+
export type FilterValueMap = {
30+
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean
31+
: C extends RangeFilter ? [number, number]
32+
: C extends SearchFilter ? string
33+
: never;
34+
};
35+
36+
// Defines all node filters in this object
37+
export const filterConfigs: FilterConfig[] = [
38+
{
39+
key: "searchText",
40+
label: "Node name/number",
41+
type: "search",
42+
predicate: (node, text: string) => {
43+
if (!text) return true;
44+
const shortName = node.user?.shortName?.toString().toLowerCase() ?? "";
45+
const longName = node.user?.longName?.toString().toLowerCase() ?? "";
46+
const nodeNum = node.num?.toString() ?? "";
47+
const nodeNumHex = numberToHexUnpadded(node.num) ?? "";
48+
const search = text.toLowerCase();
49+
return shortName.includes(search) || longName.includes(search) ||
50+
nodeNum.includes(search) ||
51+
nodeNumHex.includes(search.replace(/!/g, ""));
52+
},
53+
},
54+
{
55+
key: "favOnly",
56+
label: "Show favourites only",
57+
type: "boolean",
58+
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite,
59+
},
60+
{
61+
key: "hopRange",
62+
label: "Number of hops",
63+
type: "range",
64+
bounds: [0, 7],
65+
predicate: (node, [min, max]: [number, number]) => {
66+
const hops = node.hopsAway ?? 7;
67+
return hops >= min && hops <= max;
68+
},
69+
},
70+
{
71+
key: "channelUtilization",
72+
label: "Channel Utilization (%)",
73+
type: "range",
74+
bounds: [0, 100],
75+
predicate: (node, [min, max]: [number, number]) => {
76+
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
77+
return channelUtilization >= min && channelUtilization <= max;
78+
},
79+
},
80+
{
81+
key: "airUtilTx",
82+
label: "Airtime Utilization (%)",
83+
type: "range",
84+
bounds: [0, 100],
85+
predicate: (node, [min, max]: [number, number]) => {
86+
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
87+
return airUtilTx >= min && airUtilTx <= max;
88+
},
89+
},
90+
{
91+
key: "battery",
92+
label: "Battery level (%)",
93+
type: "range",
94+
bounds: [0, 101],
95+
predicate: (node, [min, max]: [number, number]) => {
96+
const batt = node.deviceMetrics?.batteryLevel ?? 101;
97+
return batt >= min && batt <= max;
98+
},
99+
},
100+
{
101+
key: "viaMqtt",
102+
label: "Hide MQTT-connected nodes",
103+
type: "boolean",
104+
predicate: (node, hide: boolean) => !hide || !node.viaMqtt,
105+
},
106+
];
107+
108+
export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
109+
const defaultState = useMemo(() => {
110+
return filterConfigs.reduce((acc, cfg) => {
111+
switch (cfg.type) {
112+
case "boolean":
113+
acc[cfg.key] = false;
114+
break;
115+
case "range":
116+
acc[cfg.key] = cfg.bounds!;
117+
break;
118+
case "search":
119+
acc[cfg.key] = "";
120+
break;
121+
}
122+
return acc;
123+
}, {} as FilterValueMap);
124+
}, []);
125+
126+
const [filters, setFilters] = useState<FilterValueMap>(
127+
defaultState,
128+
);
129+
130+
const resetFilters = useCallback(() => {
131+
setFilters(defaultState);
132+
}, [defaultState]);
133+
134+
const onFilterChange = useCallback(
135+
<K extends keyof FilterValueMap>(key: K, value: FilterValueMap[K]) => {
136+
setFilters((f) => ({ ...f, [key]: value }));
137+
},
138+
[],
139+
);
140+
141+
const filteredNodes = useMemo(
142+
() =>
143+
nodes.filter((node) =>
144+
filterConfigs.every((cfg) => cfg.predicate(node, filters[cfg.key]))
145+
),
146+
[nodes, filters],
147+
);
148+
149+
return {
150+
filters,
151+
onFilterChange,
152+
resetFilters,
153+
filteredNodes,
154+
filterConfigs,
155+
};
156+
}

0 commit comments

Comments
 (0)