committed by
GitHub
6 changed files with 405 additions and 4 deletions
@ -0,0 +1,87 @@ |
|||||
|
import { useId, useState } from "react"; |
||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"; |
||||
|
import { cn } from "@core/utils/cn.ts"; |
||||
|
|
||||
|
export interface SliderProps { |
||||
|
value: 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, |
||||
|
step = 1, |
||||
|
min = 0, |
||||
|
max, |
||||
|
onValueChange, |
||||
|
onValueCommit, |
||||
|
disabled = false, |
||||
|
className, |
||||
|
trackClassName, |
||||
|
rangeClassName, |
||||
|
thumbClassName, |
||||
|
...props |
||||
|
}: SliderProps) { |
||||
|
const [internalValue, setInternalValue] = useState<number[]>(value); |
||||
|
const isControlled = value !== undefined; |
||||
|
const currentValue = isControlled ? value! : internalValue; |
||||
|
const id = useId(); |
||||
|
|
||||
|
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} |
||||
|
step={step} |
||||
|
min={min} |
||||
|
max={max} |
||||
|
disabled={disabled} |
||||
|
onValueChange={handleValueChange} |
||||
|
onValueCommit={handleValueCommit} |
||||
|
{...props} |
||||
|
> |
||||
|
<SliderPrimitive.Track |
||||
|
className={cn( |
||||
|
"relative h-2 flex-1 rounded-full bg-slate-200", |
||||
|
trackClassName, |
||||
|
)} |
||||
|
> |
||||
|
<SliderPrimitive.Range |
||||
|
className={cn( |
||||
|
"absolute h-full rounded-full bg-blue-500", |
||||
|
rangeClassName, |
||||
|
)} |
||||
|
/> |
||||
|
</SliderPrimitive.Track> |
||||
|
{currentValue.map((_, i) => ( |
||||
|
<SliderPrimitive.Thumb |
||||
|
key={`${id}-thumb-${i}`} |
||||
|
className={cn( |
||||
|
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md", |
||||
|
thumbClassName, |
||||
|
)} |
||||
|
aria-label={`Thumb ${i + 1}`} |
||||
|
/> |
||||
|
))} |
||||
|
</SliderPrimitive.Root> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,156 @@ |
|||||
|
import { useCallback, useMemo, useState } from "react"; |
||||
|
import type { Protobuf } from "@meshtastic/core"; |
||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
||||
|
|
||||
|
interface BooleanFilter { |
||||
|
key: string; |
||||
|
label: string; |
||||
|
type: "boolean"; |
||||
|
predicate: (node: Node, value: boolean) => boolean; |
||||
|
} |
||||
|
|
||||
|
interface RangeFilter { |
||||
|
key: string; |
||||
|
label: string; |
||||
|
type: "range"; |
||||
|
bounds: [number, number]; |
||||
|
predicate: (node: Node, value: [number, number]) => boolean; |
||||
|
} |
||||
|
|
||||
|
interface SearchFilter { |
||||
|
key: string; |
||||
|
label: string; |
||||
|
type: "search"; |
||||
|
predicate: (node: Node, value: string) => boolean; |
||||
|
} |
||||
|
|
||||
|
export type FilterConfig = BooleanFilter | RangeFilter | SearchFilter; |
||||
|
|
||||
|
export type FilterValueMap = { |
||||
|
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean |
||||
|
: C extends RangeFilter ? [number, number] |
||||
|
: C extends SearchFilter ? string |
||||
|
: never; |
||||
|
}; |
||||
|
|
||||
|
// 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(() => { |
||||
|
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 FilterValueMap); |
||||
|
}, []); |
||||
|
|
||||
|
const [filters, setFilters] = useState<FilterValueMap>( |
||||
|
defaultState, |
||||
|
); |
||||
|
|
||||
|
const resetFilters = useCallback(() => { |
||||
|
setFilters(defaultState); |
||||
|
}, [defaultState]); |
||||
|
|
||||
|
const onFilterChange = useCallback( |
||||
|
<K extends keyof FilterValueMap>(key: K, value: FilterValueMap[K]) => { |
||||
|
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, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,135 @@ |
|||||
|
import { |
||||
|
Popover, |
||||
|
PopoverContent, |
||||
|
PopoverTrigger, |
||||
|
} 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, |
||||
|
FilterValueMap, |
||||
|
} from "@core/hooks/useNodeFilters.ts"; |
||||
|
|
||||
|
interface FilterControlProps { |
||||
|
configs: FilterConfig[]; |
||||
|
values: FilterValueMap; |
||||
|
onChange: <K extends keyof FilterValueMap>( |
||||
|
key: K, |
||||
|
value: FilterValueMap[K], |
||||
|
) => void; |
||||
|
resetFilters: () => void; |
||||
|
children?: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
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": |
||||
|
if (typeof val !== "boolean") return null; |
||||
|
return ( |
||||
|
<Checkbox |
||||
|
key={cfg.key} |
||||
|
checked={val} |
||||
|
onChange={(v) => onChange(cfg.key, v)} |
||||
|
labelClassName="dark:text-gray-900" |
||||
|
> |
||||
|
{cfg.label} |
||||
|
</Checkbox> |
||||
|
); |
||||
|
case "range": { |
||||
|
if ( |
||||
|
!Array.isArray(val) || |
||||
|
val.length !== 2 || |
||||
|
typeof val[0] !== "number" || |
||||
|
typeof val[1] !== "number" |
||||
|
) { |
||||
|
return null; |
||||
|
} |
||||
|
const [min, max] = val; |
||||
|
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) => { |
||||
|
const [newMin, newMax] = newRange; |
||||
|
onChange(cfg.key, [newMin, newMax]); |
||||
|
}} |
||||
|
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" |
||||
|
aria-label={`Slider - ${cfg.label}`} |
||||
|
/> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
case "search": |
||||
|
if (typeof val !== "string") return null; |
||||
|
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} |
||||
|
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…
Reference in new issue