From d5cf71c840aa647a4d74b017c5c229dcc3e3822e Mon Sep 17 00:00:00 2001 From: Jeremy Gallant Date: Tue, 22 Apr 2025 19:04:42 +0200 Subject: [PATCH 1/5] Add filters to node map --- package.json | 1 + src/components/UI/Checkbox/index.tsx | 7 +- src/components/UI/Slider.tsx | 79 ++++++++++++++++ src/core/hooks/useNodeFilters.ts | 132 +++++++++++++++++++++++++++ src/pages/Map/FilterControl.tsx | 104 +++++++++++++++++++++ src/pages/Map/index.tsx | 24 ++++- 6 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 src/components/UI/Slider.tsx create mode 100644 src/core/hooks/useNodeFilters.ts create mode 100644 src/pages/Map/FilterControl.tsx 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..429224c3 --- /dev/null +++ b/src/components/UI/Slider.tsx @@ -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(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 ( + + + + + {currentValue.map((_, i) => ( + + ))} + + ); +}; diff --git a/src/core/hooks/useNodeFilters.ts b/src/core/hooks/useNodeFilters.ts new file mode 100644 index 00000000..9f3d9869 --- /dev/null +++ b/src/core/hooks/useNodeFilters.ts @@ -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 { + 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>(() => { + 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); + }, []); + + const [filters, setFilters] = useState>(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 }; +} \ No newline at end of file diff --git a/src/pages/Map/FilterControl.tsx b/src/pages/Map/FilterControl.tsx new file mode 100644 index 00000000..0d13f94c --- /dev/null +++ b/src/pages/Map/FilterControl.tsx @@ -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; + onChange: (key: string, value: FilterValue) => void; +} + +export function FilterControl({ configs, values, onChange, resetFilters, children }: FilterControlProps) { + return ( + + + + + +
+ {configs.map((cfg) => { + const val = values[cfg.key]; + switch (cfg.type) { + case "boolean": + return ( + onChange(cfg.key, v as boolean)} + labelClassName="dark:text-gray-900" + > + {cfg.label} + + ); + case "range": { + const [min, max] = val as [number, number]; + const [lo, hi] = cfg.bounds!; + return ( +
+ + + 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" + /> +
+ ); + } + case "search": + 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} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index a3ec823d..6c11ee30 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,14 @@ const MapPage = () => { ) : null} + + + ); From 3dce031f8e5238ff09a7077a201467b4f109e4d2 Mon Sep 17 00:00:00 2001 From: Jeremy Gallant Date: Tue, 22 Apr 2025 23:18:26 +0200 Subject: [PATCH 2/5] Stricter typing, adjuster colors, mandatory props --- src/components/UI/Slider.tsx | 34 +++++++----- src/core/hooks/useNodeFilters.ts | 74 ++++++++++++++++--------- src/pages/Map/FilterControl.tsx | 94 +++++++++++++++++++++----------- src/pages/Map/index.tsx | 1 - 4 files changed, 131 insertions(+), 72 deletions(-) diff --git a/src/components/UI/Slider.tsx b/src/components/UI/Slider.tsx index 429224c3..5e692e9a 100644 --- a/src/components/UI/Slider.tsx +++ b/src/components/UI/Slider.tsx @@ -3,11 +3,10 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { cn } from "@core/utils/cn.ts"; export interface SliderProps { - value?: number[]; - defaultValue?: number[]; + value: number[]; step?: number; min?: number; - max?: number; + max: number; onValueChange?: (value: number[]) => void; onValueCommit?: (value: number[]) => void; disabled?: boolean; @@ -19,10 +18,9 @@ export interface SliderProps { export function Slider({ value, - defaultValue = [0], step = 1, min = 0, - max = 100, + max, onValueChange, onValueCommit, disabled = false, @@ -30,8 +28,8 @@ export function Slider({ trackClassName, rangeClassName, thumbClassName, -}:SliderProps) { - const [internalValue, setInternalValue] = useState(defaultValue); +}: SliderProps) { + const [internalValue, setInternalValue] = useState(value); const isControlled = value !== undefined; const currentValue = isControlled ? value! : internalValue; @@ -46,9 +44,11 @@ export function Slider({ return ( {currentValue.map((_, i) => ( ))} ); -}; +} diff --git a/src/core/hooks/useNodeFilters.ts b/src/core/hooks/useNodeFilters.ts index 9f3d9869..85039b16 100644 --- a/src/core/hooks/useNodeFilters.ts +++ b/src/core/hooks/useNodeFilters.ts @@ -1,24 +1,40 @@ -import { useState, useMemo, useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; import type { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -export type FilterValue = - | boolean - | [number, number] - | string[] - | string; +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; +} -export interface FilterConfig { +interface SearchFilter { key: string; label: string; - type: "boolean" | "range" | "search"; - bounds?: [number, number]; - options?: string[]; - predicate: (node: Protobuf.Mesh.NodeInfo, value: T) => boolean; + 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[] = [ +export const filterConfigs: FilterConfig[] = [ { key: "searchText", label: "Node name/number", @@ -30,7 +46,9 @@ export const filterConfigs: FilterConfig[] = [ 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, "")); + return shortName.includes(search) || longName.includes(search) || + nodeNum.includes(search) || + nodeNumHex.includes(search.replace(/!/g, "")); }, }, { @@ -84,11 +102,11 @@ export const filterConfigs: FilterConfig[] = [ label: "Hide MQTT-connected nodes", type: "boolean", predicate: (node, hide: boolean) => !hide || !node.viaMqtt, - } + }, ]; export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { - const defaultState = useMemo>(() => { + const defaultState = useMemo(() => { return filterConfigs.reduce((acc, cfg) => { switch (cfg.type) { case "boolean": @@ -102,31 +120,37 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { break; } return acc; - }, {} as Record); + }, {} as FilterValueMap); }, []); - const [filters, setFilters] = useState>(defaultState); + const [filters, setFilters] = useState( + defaultState, + ); const resetFilters = useCallback(() => { setFilters(defaultState); }, [defaultState]); const onFilterChange = useCallback( - (key: string, value: FilterValue) => { + (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]) - ) + filterConfigs.every((cfg) => cfg.predicate(node, filters[cfg.key])) ), - [nodes, filters] + [nodes, filters], ); - return { filters, onFilterChange, resetFilters, filteredNodes, filterConfigs }; -} \ No newline at end of file + return { + filters, + onFilterChange, + resetFilters, + filteredNodes, + filterConfigs, + }; +} diff --git a/src/pages/Map/FilterControl.tsx b/src/pages/Map/FilterControl.tsx index 0d13f94c..56e00986 100644 --- a/src/pages/Map/FilterControl.tsx +++ b/src/pages/Map/FilterControl.tsx @@ -1,16 +1,30 @@ -import { Popover, PopoverTrigger, PopoverContent } from "@components/UI/Popover.tsx"; +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, FilterValue } from "@core/hooks/useNodeFilters.ts"; +import type { + FilterConfig, + FilterValueMap, +} from "@core/hooks/useNodeFilters.ts"; interface FilterControlProps { configs: FilterConfig[]; - values: Record; - onChange: (key: string, value: FilterValue) => void; + values: FilterValueMap; + onChange: ( + key: K, + value: FilterValueMap[K], + ) => void; + resetFilters: () => void; + children?: React.ReactNode; } -export function FilterControl({ configs, values, onChange, resetFilters, children }: FilterControlProps) { +export function FilterControl( + { configs, values, onChange, resetFilters, children }: FilterControlProps, +) { return ( @@ -22,24 +36,38 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre - +
{configs.map((cfg) => { const val = values[cfg.key]; switch (cfg.type) { case "boolean": + if (typeof val !== "boolean") return null; return ( onChange(cfg.key, v as boolean)} + checked={val} + onChange={(v) => onChange(cfg.key, v)} labelClassName="dark:text-gray-900" - > + > {cfg.label} ); case "range": { - const [min, max] = val as [number, number]; + 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 (
@@ -51,9 +79,10 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre min={lo} max={hi} step={1} - onValueChange={(newRange) => - onChange(cfg.key, newRange as [number, number]) - } + 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" @@ -63,24 +92,25 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre ); } case "search": - 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" - /> -
- ); + 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; + default: + return null; } })} @@ -89,7 +119,7 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre onClick={resetFilters} className="w-full py-1 bg-slate-600 text-white rounded text-sm" > - Reset Filters + Reset Filters {children && ( @@ -101,4 +131,4 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre ); -} \ No newline at end of file +} diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index 6c11ee30..d0ba54fb 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -214,7 +214,6 @@ const MapPage = () => { onChange={onFilterChange} resetFilters={resetFilters} /> - ); From c6d122008b62432cf2a9c9394413896064aad528 Mon Sep 17 00:00:00 2001 From: Jeremy Gallant Date: Tue, 22 Apr 2025 23:26:49 +0200 Subject: [PATCH 3/5] Unique id --- src/components/UI/Slider.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/UI/Slider.tsx b/src/components/UI/Slider.tsx index 5e692e9a..4c73b39a 100644 --- a/src/components/UI/Slider.tsx +++ b/src/components/UI/Slider.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import * as SliderPrimitive from "@radix-ui/react-slider"; import { cn } from "@core/utils/cn.ts"; @@ -32,6 +32,7 @@ export function Slider({ 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); @@ -72,7 +73,7 @@ export function Slider({ {currentValue.map((_, i) => ( Date: Wed, 23 Apr 2025 07:10:04 +0200 Subject: [PATCH 4/5] Remove ! --- src/pages/Map/FilterControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Map/FilterControl.tsx b/src/pages/Map/FilterControl.tsx index 56e00986..7d264f1c 100644 --- a/src/pages/Map/FilterControl.tsx +++ b/src/pages/Map/FilterControl.tsx @@ -68,7 +68,7 @@ export function FilterControl( return null; } const [min, max] = val; - const [lo, hi] = cfg.bounds!; + const [lo, hi] = cfg.bounds; return (
);