Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
Expand Down
7 changes: 6 additions & 1 deletion src/components/UI/Checkbox/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useId } from "react";
import { useState, useEffect, useId } from "react";
import { Check } from "lucide-react";
import { Label } from "@components/UI/Label.tsx";
import { cn } from "@core/utils/cn.ts";
Expand Down Expand Up @@ -32,6 +32,11 @@ export function Checkbox({

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

// Make sure setIsChecked state updates with checked
useEffect(() => {
setIsChecked(checked || false);
}, [checked]);

const handleToggle = () => {
if (disabled) return;

Expand Down
79 changes: 79 additions & 0 deletions src/components/UI/Slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useState } from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@core/utils/cn.ts";

export interface SliderProps {
value?: number[];
defaultValue?: number[];
step?: number;
min?: number;
max?: number;
onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void;
disabled?: boolean;
className?: string;
trackClassName?: string;
rangeClassName?: string;
thumbClassName?: string;
}

export function Slider({
value,
defaultValue = [0],
step = 1,
min = 0,
max = 100,
onValueChange,
onValueCommit,
disabled = false,
className,
trackClassName,
rangeClassName,
thumbClassName,
}:SliderProps) {
const [internalValue, setInternalValue] = useState<number[]>(defaultValue);
const isControlled = value !== undefined;
const currentValue = isControlled ? value! : internalValue;

const handleValueChange = (newValue: number[]) => {
if (!isControlled) setInternalValue(newValue);
onValueChange?.(newValue);
};

const handleValueCommit = (newValue: number[]) => {
onValueCommit?.(newValue);
};

return (
<SliderPrimitive.Root
className={cn("relative flex items-center select-none touch-none", className)}
value={currentValue}
defaultValue={defaultValue}
step={step}
min={min}
max={max}
disabled={disabled}
onValueChange={handleValueChange}
onValueCommit={handleValueCommit}
aria-label="Slider"
>
<SliderPrimitive.Track
className={cn("relative h-2 flex-1 rounded-full bg-gray-200", trackClassName)}
>
<SliderPrimitive.Range
className={cn("absolute h-full rounded-full bg-blue-500", rangeClassName)}
/>
</SliderPrimitive.Track>
{currentValue.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
className={cn(
"block w-4 h-4 rounded-full bg-white border border-gray-400 shadow-md",
thumbClassName
)}
aria-label={`Thumb ${i + 1}`}
/>
))}
</SliderPrimitive.Root>
);
};
132 changes: 132 additions & 0 deletions src/core/hooks/useNodeFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useState, useMemo, useCallback } from "react";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";

export type FilterValue =
| boolean
| [number, number]
| string[]
| string;

export interface FilterConfig<T extends FilterValue = FilterValue> {
key: string;
label: string;
type: "boolean" | "range" | "search";
bounds?: [number, number];
options?: string[];
predicate: (node: Protobuf.Mesh.NodeInfo, value: T) => boolean;
}

// Defines all node filters in this object
export const filterConfigs: FilterConfig[] = [
{
key: "searchText",
label: "Node name/number",
type: "search",
predicate: (node, text: string) => {
if (!text) return true;
const shortName = node.user?.shortName?.toString().toLowerCase() ?? "";
const longName = node.user?.longName?.toString().toLowerCase() ?? "";
const nodeNum = node.num?.toString() ?? "";
const nodeNumHex = numberToHexUnpadded(node.num) ?? "";
const search = text.toLowerCase();
return shortName.includes(search) || longName.includes(search) || nodeNum.includes(search) || nodeNumHex.includes(search.replace(/!/g, ""));
},
},
{
key: "favOnly",
label: "Show favourites only",
type: "boolean",
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite,
},
{
key: "hopRange",
label: "Number of hops",
type: "range",
bounds: [0, 7],
predicate: (node, [min, max]: [number, number]) => {
const hops = node.hopsAway ?? 7;
return hops >= min && hops <= max;
},
},
{
key: "channelUtilization",
label: "Channel Utilization (%)",
type: "range",
bounds: [0, 100],
predicate: (node, [min, max]: [number, number]) => {
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
return channelUtilization >= min && channelUtilization <= max;
},
},
{
key: "airUtilTx",
label: "Airtime Utilization (%)",
type: "range",
bounds: [0, 100],
predicate: (node, [min, max]: [number, number]) => {
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
return airUtilTx >= min && airUtilTx <= max;
},
},
{
key: "battery",
label: "Battery level (%)",
type: "range",
bounds: [0, 101],
predicate: (node, [min, max]: [number, number]) => {
const batt = node.deviceMetrics?.batteryLevel ?? 101;
return batt >= min && batt <= max;
},
},
{
key: "viaMqtt",
label: "Hide MQTT-connected nodes",
type: "boolean",
predicate: (node, hide: boolean) => !hide || !node.viaMqtt,
}
];

export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
const defaultState = useMemo<Record<string, FilterValue>>(() => {
return filterConfigs.reduce((acc, cfg) => {
switch (cfg.type) {
case "boolean":
acc[cfg.key] = false;
break;
case "range":
acc[cfg.key] = cfg.bounds!;
break;
case "search":
acc[cfg.key] = "";
break;
}
return acc;
}, {} as Record<string, FilterValue>);
}, []);

const [filters, setFilters] = useState<Record<string, FilterValue>>(defaultState);

const resetFilters = useCallback(() => {
setFilters(defaultState);
}, [defaultState]);

const onFilterChange = useCallback(
(key: string, value: FilterValue) => {
setFilters((f) => ({ ...f, [key]: value }));
},
[]
);

const filteredNodes = useMemo(
() =>
nodes.filter((node) =>
filterConfigs.every((cfg) =>
cfg.predicate(node, filters[cfg.key])
)
),
[nodes, filters]
);

return { filters, onFilterChange, resetFilters, filteredNodes, filterConfigs };
}
104 changes: 104 additions & 0 deletions src/pages/Map/FilterControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Popover, PopoverTrigger, PopoverContent } from "@components/UI/Popover.tsx";
import { FunnelIcon } from "lucide-react";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Slider } from "@components/UI/Slider.tsx";
import type { FilterConfig, FilterValue } from "@core/hooks/useNodeFilters.ts";

interface FilterControlProps {
configs: FilterConfig[];
values: Record<string, FilterValue>;
onChange: (key: string, value: FilterValue) => void;
}

export function FilterControl({ configs, values, onChange, resetFilters, children }: FilterControlProps) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="fixed bottom-17 right-2 px-1 py-1 bg-slate-100 text-slate-600 rounded shadow-md"
aria-label="Filter"
>
<FunnelIcon />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" sideOffset={12} className="dark:bg-slate-100 dark:border-slate-300">
<div className="space-y-4">
{configs.map((cfg) => {
const val = values[cfg.key];
switch (cfg.type) {
case "boolean":
return (
<Checkbox
key={cfg.key}
checked={val as boolean}
onChange={(v) => onChange(cfg.key, v as boolean)}
labelClassName="dark:text-gray-900"
>
{cfg.label}
</Checkbox>
);
case "range": {
const [min, max] = val as [number, number];
const [lo, hi] = cfg.bounds!;
return (
<div key={cfg.key} className="space-y-2">
<label className="block text-sm font-medium">
{cfg.label}: {min} – {max}
</label>
<Slider
value={[min, max]}
min={lo}
max={hi}
step={1}
onValueChange={(newRange) =>
onChange(cfg.key, newRange as [number, number])
}
className="w-full"
trackClassName="h-1 bg-gray-200 dark:bg-slate-700"
rangeClassName="bg-blue-500"
thumbClassName="w-3 h-3 bg-white border border-gray-400 dark:border-slate-600"
/>
</div>
);
}
case "search":
return (
<div key={cfg.key} className="flex flex-col space-y-1">
<label htmlFor={cfg.key} className="font-medium text-sm">
{cfg.label}
</label>
<input
id={cfg.key}
type="text"
value={val as string}
onChange={(e) => onChange(cfg.key, e.target.value)}
placeholder="Search phrase"
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600"
/>
</div>
);

default:
return null;
}
})}

<button
type="button"
onClick={resetFilters}
className="w-full py-1 bg-slate-600 text-white rounded text-sm"
>
Reset Filters
</button>

{children && (
<div className="mt-4 border-t pt-4">
{children}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
Loading