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