diff --git a/package.json b/package.json index 22308979..a8f7b756 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/UI/Checkbox/index.tsx b/src/components/UI/Checkbox/index.tsx index 59240c79..3f6baeb2 100644 --- a/src/components/UI/Checkbox/index.tsx +++ b/src/components/UI/Checkbox/index.tsx @@ -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"; @@ -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; diff --git a/src/components/UI/Slider.tsx b/src/components/UI/Slider.tsx new file mode 100644 index 00000000..593c1bf0 --- /dev/null +++ b/src/components/UI/Slider.tsx @@ -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(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 ( + + + + + {currentValue.map((_, i) => ( + + ))} + + ); +} diff --git a/src/core/hooks/useNodeFilters.ts b/src/core/hooks/useNodeFilters.ts new file mode 100644 index 00000000..85039b16 --- /dev/null +++ b/src/core/hooks/useNodeFilters.ts @@ -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( + defaultState, + ); + + const resetFilters = useCallback(() => { + setFilters(defaultState); + }, [defaultState]); + + const onFilterChange = useCallback( + (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, + }; +} diff --git a/src/pages/Map/FilterControl.tsx b/src/pages/Map/FilterControl.tsx new file mode 100644 index 00000000..a8313d5d --- /dev/null +++ b/src/pages/Map/FilterControl.tsx @@ -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: ( + key: K, + value: FilterValueMap[K], + ) => void; + resetFilters: () => void; + children?: React.ReactNode; +} + +export function FilterControl( + { configs, values, onChange, resetFilters, children }: FilterControlProps, +) { + return ( + + + + + +
+ {configs.map((cfg) => { + const val = values[cfg.key]; + switch (cfg.type) { + case "boolean": + if (typeof val !== "boolean") return null; + return ( + onChange(cfg.key, v)} + labelClassName="dark:text-gray-900" + > + {cfg.label} + + ); + 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 ( +
+ + { + 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}`} + /> +
+ ); + } + case "search": + if (typeof val !== "string") return null; + return ( +
+ + 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" + /> +
+ ); + + default: + return null; + } + })} + + + + {children && ( +
+ {children} +
+ )} +
+
+
+ ); +} diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index a3ec823d..d0ba54fb 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -18,6 +18,8 @@ import { useMap, } from "react-map-gl/maplibre"; import MapGl from "react-map-gl/maplibre"; +import { useNodeFilters } from "@core/hooks/useNodeFilters.ts"; +import { FilterControl } from "@pages/Map/FilterControl.tsx"; type NodePosition = { latitude: number; @@ -53,6 +55,14 @@ const MapPage = () => { [nodes], ); + const { + filteredNodes, + filters, + onFilterChange, + resetFilters, + filterConfigs, + } = useNodeFilters(validNodes); + const handleMarkerClick = useCallback( (node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => { event?.originalEvent?.stopPropagation(); @@ -106,12 +116,12 @@ const MapPage = () => { if (center) { map.easeTo(center); } - }, [validNodes, map]); + }, [filteredNodes, map]); // Generate all markers const markers = useMemo( () => - validNodes.map((node) => { + filteredNodes.map((node) => { const position = convertToLatLng(node.position); return ( { ); }), - [validNodes, handleMarkerClick], + [filteredNodes, handleMarkerClick], ); useEffect(() => { @@ -197,6 +207,13 @@ const MapPage = () => { ) : null} + + );