From a642080b90c644bda8649849c0d65a04b57c1219 Mon Sep 17 00:00:00 2001 From: philon- <8975765+philon-@users.noreply.github.com> Date: Thu, 22 May 2025 21:11:32 +0200 Subject: [PATCH] Filter rework (#623) * Rework filtering Created common FilterComponents Created common FilterControl Abstracted common map logic into Map component Reworked Node filtering for map page Added Node filtering for nodes list Added test for node filtering Added toggle group UI package * Debounce filterState change * UI adjustments --------- Co-authored-by: philon- --- package.json | 1 + src/components/Map.tsx | 58 +++ src/components/UI/Accordion.tsx | 4 +- src/components/UI/ToggleGroup.tsx | 48 +++ .../generic/Filter/FilterComponents.tsx | 218 ++++++++++++ .../generic/Filter/FilterControl.tsx | 329 ++++++++++++++++++ .../generic/Filter/useFilterNode.test.ts | 160 +++++++++ .../generic/Filter/useFilterNode.ts | 153 ++++++++ src/components/generic/Table/index.tsx | 6 +- src/core/hooks/useNodeFilters.ts | 294 ---------------- src/pages/Map/FilterControl.tsx | 265 -------------- src/pages/Map/index.tsx | 153 +++----- src/pages/Nodes.tsx | 66 +++- 13 files changed, 1078 insertions(+), 677 deletions(-) create mode 100644 src/components/Map.tsx create mode 100644 src/components/UI/ToggleGroup.tsx create mode 100644 src/components/generic/Filter/FilterComponents.tsx create mode 100644 src/components/generic/Filter/FilterControl.tsx create mode 100644 src/components/generic/Filter/useFilterNode.test.ts create mode 100644 src/components/generic/Filter/useFilterNode.ts delete mode 100644 src/core/hooks/useNodeFilters.ts delete mode 100644 src/pages/Map/FilterControl.tsx diff --git a/package.json b/package.json index 09aa80c6..ccaa07a0 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@radix-ui/react-switch": "^1.2.2", "@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-toast": "^1.2.11", + "@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.4", "@turf/turf": "^7.2.0", "base64-js": "^1.5.1", diff --git a/src/components/Map.tsx b/src/components/Map.tsx new file mode 100644 index 00000000..9a3946f5 --- /dev/null +++ b/src/components/Map.tsx @@ -0,0 +1,58 @@ +import MapGl, { + AttributionControl, + GeolocateControl, + type MapRef, + NavigationControl, + ScaleControl, +} from "react-map-gl/maplibre"; +import { useTheme } from "@core/hooks/useTheme.ts"; +import { useEffect, useRef } from "react"; + +interface MapProps { + children?: React.ReactNode; + onLoad?: (map: MapRef) => void; +} + +export const Map = ({ children, onLoad }: MapProps) => { + const { theme } = useTheme(); + const darkMode = theme === "dark"; + const mapRef = useRef(null); + + useEffect(() => { + const map = mapRef.current; + if (map && onLoad) onLoad(map); + }, [onLoad]); + + return ( + + + + + + {children} + + ); +}; diff --git a/src/components/UI/Accordion.tsx b/src/components/UI/Accordion.tsx index a1252b9b..e4d54cec 100644 --- a/src/components/UI/Accordion.tsx +++ b/src/components/UI/Accordion.tsx @@ -16,7 +16,7 @@ export const AccordionTrigger = forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/generic/Filter/FilterComponents.tsx b/src/components/generic/Filter/FilterComponents.tsx new file mode 100644 index 00000000..b674e219 --- /dev/null +++ b/src/components/generic/Filter/FilterComponents.tsx @@ -0,0 +1,218 @@ +import { + AccordionContent, + AccordionHeader, + AccordionItem, + AccordionTrigger, +} from "@components/UI/Accordion.tsx"; +import { Checkbox } from "@components/UI/Checkbox/index.tsx"; +import { Slider } from "@components/UI/Slider.tsx"; +import { ScrollArea } from "@components/UI/ScrollArea.tsx"; +import { ToggleGroup, ToggleGroupItem } from "@components/UI/ToggleGroup.tsx"; + +import { ReactNode } from "react"; +import type { FilterState } from "@components/generic/Filter/useFilterNode.ts"; +import { cn } from "@core/utils/cn.ts"; + +interface FilterAccordionItemProps { + label: string; + children?: ReactNode; +} + +type RangeKeys = { + [K in keyof T]: T[K] extends [number, number] ? K : never; +}[keyof T]; +interface FilterSliderProps> { + filterKey: K; + filterState: FilterState; + defaultFilterValues: FilterState; + onChange: (key: K) => (value: number[]) => void; + labelContent?: React.ReactNode; + label?: string; + step?: number; +} + +type EnumArrayKeys = { + [K in keyof T]: T[K] extends number[] ? K : never; +}[keyof T]; +interface FilterMultiProps> { + filterKey: K; + options: number[]; + filterState: FilterState; + setFilterState: React.Dispatch>; + getLabel?: (value: number) => string; +} + +interface FilterToggleProps { + label: string; + alternativeLabels: [string, string]; + filterKey: K; + filterState: FilterState; + onChange: (key: K, value: string) => void; +} + +export const FilterAccordionItem = ({ + label, + children, +}: FilterAccordionItemProps) => { + return ( + + + + {label} + + + + {children} + + + ); +}; + +export const FilterSlider = >({ + filterKey, + filterState, + defaultFilterValues, + onChange, + labelContent, + label, + step, +}: FilterSliderProps) => { + const value: [number, number] = filterState[filterKey]; + const defaultValue: [number, number] = defaultFilterValues[filterKey]; + + const showRange = value[0] !== value[1]; + const defaultLabel = ( + <> + {label}: {value[0]} + {showRange ? ` — ${value[1]}` : ""} + + ); + + return ( +
+ + +
+ ); +}; + +function getNumberArray>( + state: T, + key: K, +): number[] { + return state[key] as unknown as number[]; +} +export const FilterMulti = >({ + filterKey, + options, + filterState, + setFilterState, + getLabel = (v) => String(v), +}: FilterMultiProps) => { + const selected = getNumberArray(filterState, filterKey); + + const allSelected = options.length > 0 && + options.every((opt) => selected.includes(opt)); + + const toggleAll = () => { + setFilterState((prev) => ({ + ...prev, + [filterKey]: allSelected ? [] : [...options], + })); + }; + + const toggleValue = (val: number, checked: boolean) => { + setFilterState((prev) => { + const current = getNumberArray(prev, filterKey); + return { + ...prev, + [filterKey]: checked + ? [...current, val] + : current.filter((v) => v !== val), + }; + }); + }; + + return ( +
+ +
+ + {options.map((val) => ( + toggleValue(val, checked)} + > + {getLabel(val)} + + ))} +
+
+
+ ); +}; + +export const FilterToggle = ({ + label, + alternativeLabels, + filterKey, + filterState, + onChange, +}: FilterToggleProps) => ( +
+ + onChange(filterKey, value)} + value={typeof filterState[filterKey] === "undefined" + ? "" + : filterState[filterKey].toString()} + > + + {alternativeLabels[0]} + + + {alternativeLabels[1]} + + +
+); diff --git a/src/components/generic/Filter/FilterControl.tsx b/src/components/generic/Filter/FilterControl.tsx new file mode 100644 index 00000000..7e4f39aa --- /dev/null +++ b/src/components/generic/Filter/FilterControl.tsx @@ -0,0 +1,329 @@ +import { + type ComponentProps, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { Protobuf } from "@meshtastic/core"; +import { debounce } from "@core/utils/debounce.ts"; +import { cn } from "@core/utils/cn.ts"; +import { TimeAgo } from "@components/generic/TimeAgo.tsx"; +import type { FilterState } from "@components/generic/Filter/useFilterNode.ts"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@components/UI/Popover.tsx"; +import { Input } from "@components/UI/Input.tsx"; +import { Accordion } from "@components/UI/Accordion.tsx"; +import { FunnelIcon } from "lucide-react"; + +import { + FilterAccordionItem, + FilterMulti, + FilterSlider, + FilterToggle, +} from "@components/generic/Filter/FilterComponents.tsx"; + +type PopoverContentProps = ComponentProps; + +interface FilterControlProps { + filterState: FilterState; + defaultFilterValues: FilterState; + setFilterState: React.Dispatch>; + isDirty?: boolean; + parameters?: { + popoverContentProps?: Partial; + triggerIcon?: ReactNode; + popoverTriggerClassName?: string; + showTextSearch?: boolean; + }; + + children?: ReactNode; +} + +export function FilterControl({ + filterState, + defaultFilterValues, + setFilterState, + isDirty, + parameters, + children, +}: FilterControlProps) { + // Copy of the state that we only use for rendering sliders and their labels directly, rest is debounced + const [localFilterState, setLocalFilterState] = useState(filterState); + const skipNextSync = useRef(false); + useEffect(() => { + if (skipNextSync.current) { + skipNextSync.current = false; + return; + } + setLocalFilterState(filterState); + }, [filterState]); + + const handleTextChange = + (key: K) => + (e: React.ChangeEvent) => { + setFilterState((prev) => ({ + ...prev, + [key]: e.target.value, + })); + }; + const handleRangeChange = + (key: K) => (value: number[]) => { + // immediate slider update + setLocalFilterState((prev) => ({ + ...prev, + [key]: value, + })); + + // debounced write to filterState (table/map render) + debounce( + () => { + skipNextSync.current = true; + setFilterState((prev) => ({ + ...prev, + [key]: value, + })); + }, + 250, + )(); + }; + const handleBoolChange = ( + key: K, + value: string, + ) => { + const typedValue = value === "" + ? undefined + : JSON.parse(value.toLowerCase()); + + setFilterState((prev) => ({ + ...prev, + [key]: typedValue, + })); + }; + + const resetFilters = () => { + setFilterState(defaultFilterValues); + }; + + function formatTS(ts: number): ReactNode { + return ; + } + function formatEnumLabel(label: string): string { + return label.replace(/_/g, " "); + } + + return ( + + + + + +
+ + + {(parameters?.showTextSearch ?? true) && ( +
+ + +
+ )} + + + Number of hops: {localFilterState.hopsAway[0] === 0 + ? "Direct" + : localFilterState.hopsAway[0]} + {localFilterState.hopsAway[0] !== + localFilterState.hopsAway[1] + ? " — " + localFilterState.hopsAway[1] + : ""} + + } + /> + + + Last heard:
+ {localFilterState.lastHeard[0] === 0 ? "Now" : ( + <> + {localFilterState.lastHeard[0] === + defaultFilterValues.lastHeard[1] && ">"} + {formatTS(localFilterState.lastHeard[0])} + + )} + {localFilterState.lastHeard[0] !== + localFilterState.lastHeard[1] && ( + <> + {" — "} + {localFilterState.lastHeard[1] === + defaultFilterValues.lastHeard[1] && ">"} + {formatTS(localFilterState.lastHeard[1])} + + )} + + } + /> + + +
+ + + + + + + Battery level (%): {localFilterState.batteryLevel[0] === 101 + ? "Plugged in" + : localFilterState.batteryLevel[0]} + {localFilterState.batteryLevel[0] !== + localFilterState.batteryLevel[1] && ( + <> + {" – "} + {localFilterState.batteryLevel[1] === 101 + ? "Plugged in" + : localFilterState.batteryLevel[1]} + + )} + + } + /> + + + + + typeof v === "number", + )} + getLabel={(val) => + formatEnumLabel( + Protobuf.Config.Config_DeviceConfig_Role[val], + )} + /> + + + typeof v === "number", + )} + getLabel={(val) => + formatEnumLabel(Protobuf.Mesh.HardwareModel[val])} + /> + +
+ + {children && ( +
+ {children} +
+ )} +
+
+
+ ); +} diff --git a/src/components/generic/Filter/useFilterNode.test.ts b/src/components/generic/Filter/useFilterNode.test.ts new file mode 100644 index 00000000..02e071ce --- /dev/null +++ b/src/components/generic/Filter/useFilterNode.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { useFilterNode } from "@components/generic/Filter/useFilterNode.ts"; +import { Protobuf } from "@meshtastic/core"; +import { renderHook } from "@testing-library/react"; + +export function createMockNode(): Protobuf.Mesh.NodeInfo { + return { + $typeName: "meshtastic.NodeInfo", + num: 1234567890, + snr: -10.2, + lastHeard: 1747519674, + channel: 0, + viaMqtt: false, + isFavorite: true, + isIgnored: false, + hopsAway: 2, + user: { + $typeName: "meshtastic.User", + id: "!12345678", + longName: "longName", + shortName: "lN", + macaddr: new Uint8Array(0), + hwModel: Protobuf.Mesh.HardwareModel.TLORA_T3_S3, + isLicensed: false, + role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT, + publicKey: new Uint8Array(32), + }, + deviceMetrics: { + $typeName: "meshtastic.DeviceMetrics", + batteryLevel: 101, + voltage: 4.21, + channelUtilization: 7.50, + airUtilTx: 2.57, + uptimeSeconds: 528092, + }, + }; +} + +describe("useFilterNode", () => { + const { result } = renderHook(() => useFilterNode()); + const { nodeFilter, defaultFilterValues, isFilterDirty } = result.current; + + describe("nodeFilter", () => { + const node = createMockNode(); + + it("filters by nodeName", () => { + expect(nodeFilter(node, { nodeName: "lon" })).toBe(true); + expect(nodeFilter(node, { nodeName: "xxx" })).toBe(false); + }); + + it("filters by hopsAway", () => { + expect(nodeFilter(node, { hopsAway: [0, 1] })).toBe(false); + expect(nodeFilter(node, { hopsAway: [2, 2] })).toBe(true); + }); + + it("filters by snr", () => { + expect(nodeFilter(node, { snr: [-12, -10] })).toBe(true); + expect(nodeFilter(node, { snr: [-17, -16] })).toBe(false); + }); + + it("filters by batteryLevel", () => { + expect(nodeFilter(node, { batteryLevel: [0, 100] })).toBe(false); + expect(nodeFilter(node, { batteryLevel: [100, 101] })).toBe(true); + }); + + it("filters by isFavorite", () => { + expect(nodeFilter(node, { isFavorite: true })).toBe(true); + expect(nodeFilter(node, { isFavorite: false })).toBe(false); + expect(nodeFilter(node, { isFavorite: undefined })).toBe(true); + }); + + it("filters by viaMqtt", () => { + expect(nodeFilter(node, { viaMqtt: true })).toBe(false); + expect(nodeFilter(node, { viaMqtt: false })).toBe(true); + expect(nodeFilter(node, { viaMqtt: undefined })).toBe(true); + }); + + it("filters by airUtilTx", () => { + expect(nodeFilter(node, { airUtilTx: [2, 3] })).toBe(true); + expect(nodeFilter(node, { airUtilTx: [3, 4] })).toBe(false); + }); + + it("filters by channelUtilization", () => { + expect(nodeFilter(node, { channelUtilization: [7, 8] })).toBe(true); + expect(nodeFilter(node, { channelUtilization: [8, 9] })).toBe(false); + }); + + it("filters by voltage", () => { + expect(nodeFilter(node, { voltage: [4, 4.3] })).toBe(true); + expect(nodeFilter(node, { voltage: [4.3, 5] })).toBe(false); + }); + + it("filters by role", () => { + expect( + nodeFilter(node, { + role: [Protobuf.Config.Config_DeviceConfig_Role.CLIENT], + }), + ).toBe(true); + expect( + nodeFilter(node, { + role: [Protobuf.Config.Config_DeviceConfig_Role.REPEATER], + }), + ).toBe(false); + }); + + it("filters by hwModel", () => { + expect( + nodeFilter(node, { + hwModel: [Protobuf.Mesh.HardwareModel.TLORA_T3_S3], + }), + ).toBe(true); + expect( + nodeFilter(node, { hwModel: [Protobuf.Mesh.HardwareModel.HELTEC_V3] }), + ).toBe(false); + }); + + it("returns false when current matches defaults", () => { + expect(isFilterDirty(defaultFilterValues)).toBe(false); + }); + + it("detects dirty string field", () => { + const modified = { ...defaultFilterValues, nodeName: "abc" }; + expect(isFilterDirty(modified)).toBe(true); + }); + + it("detects dirty numeric tuple field", () => { + const modified = { + ...defaultFilterValues, + snr: [-10, 5] as [number, number], + }; + expect(isFilterDirty(modified)).toBe(true); + }); + + it("detects dirty boolean field (isFavorite)", () => { + const modified = { ...defaultFilterValues, isFavorite: true }; + expect(isFilterDirty(modified)).toBe(true); + }); + + it("detects dirty boolean field (viaMqtt)", () => { + const modified = { ...defaultFilterValues, viaMqtt: true }; + expect(isFilterDirty(modified)).toBe(true); + }); + + it("detects dirty enum array field (role)", () => { + const modified = { + ...defaultFilterValues, + role: [Protobuf.Config.Config_DeviceConfig_Role.REPEATER], + }; + expect(isFilterDirty(modified)).toBe(true); + }); + + it("detects dirty enum array field (hwModel)", () => { + const modified = { + ...defaultFilterValues, + hwModel: [Protobuf.Mesh.HardwareModel.HELTEC_V3], + }; + expect(isFilterDirty(modified)).toBe(true); + }); + }); +}); diff --git a/src/components/generic/Filter/useFilterNode.ts b/src/components/generic/Filter/useFilterNode.ts new file mode 100644 index 00000000..da3b7347 --- /dev/null +++ b/src/components/generic/Filter/useFilterNode.ts @@ -0,0 +1,153 @@ +import { Protobuf } from "@meshtastic/core"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import { useMemo } from "react"; + +export type FilterState = { + nodeName: string; + hopsAway: [number, number]; + lastHeard: [number, number]; + isFavorite: boolean | undefined; // undefined -> don't filter + viaMqtt: boolean | undefined; // undefined -> don't filter + snr: [number, number]; + channelUtilization: [number, number]; + airUtilTx: [number, number]; + batteryLevel: [number, number]; + voltage: [number, number]; + role: (Protobuf.Config.Config_DeviceConfig_Role)[]; + hwModel: (Protobuf.Mesh.HardwareModel)[]; +}; + +export function useFilterNode() { + const defaultFilterValues = useMemo(() => ({ + nodeName: "", + hopsAway: [0, 7], + lastHeard: [0, 864000], // 0-10 days + isFavorite: undefined, + viaMqtt: undefined, + snr: [-20, 10], + channelUtilization: [0, 100], + airUtilTx: [0, 100], + batteryLevel: [0, 101], + voltage: [0, 5], + role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( + (v): v is Protobuf.Config.Config_DeviceConfig_Role => + typeof v === "number", + ), + hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( + (v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", + ), + }), []); + + function nodeFilter( + node: Protobuf.Mesh.NodeInfo, + filterOverrides?: Partial, + ): boolean { + const filterState: FilterState = { + ...defaultFilterValues, + ...filterOverrides, + }; + + if (!node.user) return false; + + const nodeName = filterState.nodeName.toLowerCase(); + if ( + !( + node.user?.shortName.toLowerCase().includes(nodeName) || + node.user?.longName.toLowerCase().includes(nodeName) || + node?.num.toString().includes(nodeName) || + numberToHexUnpadded(node?.num).includes( + nodeName.replace(/!/g, ""), + ) + ) + ) return false; + + const hops = node?.hopsAway ?? 7; + if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { + return false; + } + + const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0); + if ( + secondsAgo < filterState.lastHeard[0] || + ( + secondsAgo > filterState.lastHeard[1] && + filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1] + ) + ) return false; + + if ( + typeof filterState.isFavorite !== "undefined" && + node.isFavorite !== filterState.isFavorite + ) return false; + + if ( + typeof filterState.viaMqtt !== "undefined" && + node.viaMqtt !== filterState.viaMqtt + ) return false; + + const snr = node?.snr ?? -20; + if ( + snr < filterState.snr[0] || + snr > filterState.snr[1] + ) return false; + + const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0; + if ( + channelUtilization < filterState.channelUtilization[0] || + channelUtilization > filterState.channelUtilization[1] + ) return false; + + const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0; + if ( + airUtilTx < filterState.airUtilTx[0] || + airUtilTx > filterState.airUtilTx[1] + ) return false; + + const batt = node?.deviceMetrics?.batteryLevel ?? 101; + if ( + batt < filterState.batteryLevel[0] || + batt > filterState.batteryLevel[1] + ) return false; + + const voltage = node?.deviceMetrics?.voltage ?? 0; + if ( + voltage < filterState.voltage[0] || + voltage > filterState.voltage[1] + ) return false; + + const role: Protobuf.Config.Config_DeviceConfig_Role = node.user?.role ?? + Protobuf.Config.Config_DeviceConfig_Role.CLIENT; + if (!filterState.role.includes(role)) return false; + + const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ?? + Protobuf.Mesh.HardwareModel.UNSET; + if (!filterState.hwModel.includes(hwModel)) return false; + + // All conditions are true + return true; + } + + // deno-lint-ignore no-explicit-any + function shallowEqualArray(a: any[], b: any[]) { + return a.length === b.length && a.every((v, i) => v === b[i]); + } + + function isFilterDirty( + current: FilterState, + overrides?: Partial, + ): boolean { + const base: FilterState = overrides + ? { ...defaultFilterValues, ...overrides } + : defaultFilterValues; + + return (Object.keys(base) as (keyof FilterState)[]).some((key) => { + const curr = current[key]; + const def = base[key]; + return Array.isArray(def) && Array.isArray(curr) + ? !shallowEqualArray(curr, def) + : curr !== def; + }); + } + + return { nodeFilter, defaultFilterValues, isFilterDirty }; +} diff --git a/src/components/generic/Table/index.tsx b/src/components/generic/Table/index.tsx index eb784c19..ae66a730 100755 --- a/src/components/generic/Table/index.tsx +++ b/src/components/generic/Table/index.tsx @@ -114,7 +114,7 @@ export const Table = ({ headings, rows }: TableProps) => { }); return ( - +
{headings.map((heading) => ( @@ -128,7 +128,9 @@ export const Table = ({ headings, rows }: TableProps) => { }`} onClick={() => heading.sortable && headingSort(heading.title)} onKeyUp={(e) => { - if (heading.sortable && (e.key === "Enter" || e.key === " ")) { + if ( + heading.sortable && (e.key === "Enter" || e.key === " ") + ) { headingSort(heading.title); } }} diff --git a/src/core/hooks/useNodeFilters.ts b/src/core/hooks/useNodeFilters.ts deleted file mode 100644 index 529a2773..00000000 --- a/src/core/hooks/useNodeFilters.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { useCallback, useMemo, useState } from "react"; -import { Protobuf } from "@meshtastic/core"; -import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; - -interface BooleanFilter { - key: string; - label: string; - group: string; - type: "boolean"; - predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean; -} - -interface RangeFilter { - key: string; - label: string; - group: string; - type: "range"; - bounds: [number, number]; - predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean; -} - -interface SearchFilter { - key: string; - label: string; - group: string; - type: "search"; - predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean; -} - -interface MultiFilter { - key: string; - label: string; - group: string; - type: "multi"; - options: string[]; - predicate: (node: Protobuf.Mesh.NodeInfo, value: string[]) => boolean; -} - -export type FilterConfig = - | BooleanFilter - | RangeFilter - | SearchFilter - | MultiFilter; - -export type FilterValueMap = { - [C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean - : C extends RangeFilter ? [number, number] - : C extends SearchFilter ? string - : C extends MultiFilter ? string[] - : never; -}; - -// Defines all node filters in this object -export const filterConfigs: FilterConfig[] = [ - { - key: "searchText", - label: "Node name/number", - group: "General", - 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: "hopRange", - label: "Number of hops", - group: "General", - type: "range", - bounds: [0, 7], - predicate: (node, [min, max]: [number, number]) => { - const hops = node.hopsAway ?? 7; - return hops >= min && hops <= max; - }, - }, - { - key: "lastHeard", - label: "Last heard", - group: "General", - type: "range", - bounds: [0, 864000], // 10 days - predicate: (node, [min, max]: [number, number]) => { - const secondsAgo = Date.now() / 1000 - node.lastHeard; - return (secondsAgo >= min && secondsAgo <= max) || - (secondsAgo >= min && max == 864000); - }, - }, - { - key: "favOnly", - label: "Show favourites only", - group: "General", - type: "boolean", - predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite, - }, - { - key: "viaMqtt", - label: "Hide MQTT-connected nodes", - group: "General", - type: "boolean", - predicate: (node, hide: boolean) => !hide || !node.viaMqtt, - }, - { - key: "snr", - label: "SNR (db)", - group: "Metrics", - type: "range", - bounds: [-20, 10], - predicate: (node, [min, max]: [number, number]) => { - const snr = node.snr ?? -20; - return snr >= min && snr <= max; - }, - }, - { - key: "channelUtilization", - label: "Channel Utilization (%)", - group: "Metrics", - 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 (%)", - group: "Metrics", - 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 (%)", - group: "Metrics", - type: "range", - bounds: [0, 101], - predicate: (node, [min, max]: [number, number]) => { - const batt = node.deviceMetrics?.batteryLevel ?? 101; - return batt >= min && batt <= max; - }, - }, - { - key: "voltage", - label: "Battery voltage (V)", - group: "Metrics", - type: "range", - bounds: [0.1, 5.0], - predicate: (node, [min, max]: [number, number]) => { - const batt = node.deviceMetrics?.voltage ?? 5; - return batt >= min && batt <= max; - }, - }, - { - key: "role", - label: "Role", - group: "Role", - type: "multi", - options: Object.keys(Protobuf.Config.Config_DeviceConfig_Role) - .filter((k) => isNaN(Number(k))) - .map((k) => { - const spaced = k.replace(/_/g, " "); - return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); - }), - predicate: (node, selected) => { - return selected.map((k) => { - const unSpaced = k.replace(/ /g, "_"); - return unSpaced.toUpperCase(); - }).includes( - Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0], - ); - }, - }, - { - key: "hwModel", - label: "Hardware model", - group: "Hardware", - type: "multi", - options: Object.keys(Protobuf.Mesh.HardwareModel) - .filter((k) => isNaN(Number(k))) - .map((k) => { - return k.replace(/_/g, " "); - }), - predicate: (node, selected) => { - return selected.map((k) => { - const unSpaced = k.replace(/ /g, "_"); - return unSpaced.toUpperCase(); - }).includes(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]); - }, - }, -]; - -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; - case "multi": - acc[cfg.key] = cfg.options; - break; - } - return acc; - }, {} as FilterValueMap); - }, []); - - const [filters, setFilters] = useState( - defaultState, - ); - - const groupedFilterConfigs = useMemo(() => { - return filterConfigs.reduce>((acc, cfg) => { - const g = "group" in cfg ? cfg.group : "General"; - if (!acc[g]) acc[g] = []; - acc[g].push(cfg); - return acc; - }, {}); - }, [filterConfigs]); - - 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) => { - const val = filters[cfg.key]; - switch (cfg.type) { - case "boolean": - if (typeof val !== "boolean") return true; - return cfg.predicate(node, val); - - case "range": { - if ( - !Array.isArray(val) || - val.length !== 2 || - typeof val[0] !== "number" || - typeof val[1] !== "number" - ) { - return true; - } - const tuple: [number, number] = [val[0], val[1]]; - return cfg.predicate(node, tuple); - } - case "multi": { - const safeArray = (() => { - if (!Array.isArray(val)) return []; - return val.filter((x): x is string => typeof x === "string"); - })(); - return cfg.predicate(node, safeArray); - } - case "search": - if (typeof val !== "string") return true; - return cfg.predicate(node, val); - } - }) - ), - [nodes, filters], - ); - - return { - filters, - defaultState, - onFilterChange, - resetFilters, - filteredNodes, - groupedFilterConfigs, - }; -} diff --git a/src/pages/Map/FilterControl.tsx b/src/pages/Map/FilterControl.tsx deleted file mode 100644 index b8fee202..00000000 --- a/src/pages/Map/FilterControl.tsx +++ /dev/null @@ -1,265 +0,0 @@ -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 { ScrollArea } from "@components/UI/ScrollArea.tsx"; -import { - Accordion, - AccordionContent, - AccordionHeader, - AccordionItem, - AccordionTrigger, -} from "@components/UI/Accordion.tsx"; -import type { - FilterConfig, - FilterValueMap, -} from "@core/hooks/useNodeFilters.ts"; -import { cn } from "@core/utils/cn.ts"; -import { TimeAgo } from "@components/generic/TimeAgo.tsx"; - -interface FilterControlProps { - groupedFilterConfigs: Record; - values: FilterValueMap; - onChange: ( - key: K, - value: FilterValueMap[K], - ) => void; - resetFilters: () => void; - isDirty: boolean; - children?: React.ReactNode; -} - -export function FilterControl( - { groupedFilterConfigs, values, onChange, resetFilters, isDirty, children }: - FilterControlProps, -) { - return ( - - - - - -
- - {Object.entries(groupedFilterConfigs).map(( - [groupName, groupConfigs], - ) => ( - - - - {groupName} - - - - {groupConfigs.map((cfg) => { - const val = values[cfg.key]; - switch (cfg.type) { - case "boolean": - if (typeof val !== "boolean") return null; - return ( - onChange(cfg.key, v)} - className="pb-1" - labelClassName="dark:text-slate-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; - - let formattedMin = null; - let formattedMax = null; - - // Some filters require special formatting for min/max values - if (cfg.key == "battery" && min == hi) { - formattedMin = "Charging"; - } - if (cfg.key == "battery" && max == hi) { - formattedMax = "Charging"; - } - if (cfg.key == "hopRange" && min == lo) { - formattedMin = "Direct"; - } - if (cfg.key == "lastHeard") { - formattedMin = ( - <> -
- {min === lo ? "now" : ( - - )} - - ); - - formattedMax = ( - <> - {max === hi ? ">" : ""} - - - ); - } - - return ( -
- - { - const [newMin, newMax] = newRange; - onChange(cfg.key, [newMin, newMax]); - }} - className="w-full pb-3" - trackClassName="h-1 bg-slate-200 dark:bg-slate-700" - rangeClassName="bg-blue-500" - thumbClassName="w-3 h-3 bg-white border border-slate-400 dark:border-slate-600" - aria-label={`Slider - ${cfg.label}`} - /> -
- ); - } - case "multi": { - const safeArray = (() => { - if (!Array.isArray(val)) return []; - return val.filter((x): x is string => - typeof x === "string" - ); - })(); - - const allSelected = cfg.options.length > 0 && - cfg.options.every((opt) => safeArray.includes(opt)); - - return ( - -
- - {cfg.options.map((opt) => ( - - onChange( - cfg.key, - checked - ? [...safeArray, opt] - : safeArray.filter((s) => s !== opt), - )} - > - {opt} - - ))} -
-
- ); - } - 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 d08451b7..80089324 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -1,51 +1,44 @@ import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx"; import { Avatar } from "../../components/UI/Avatar.tsx"; -import { useTheme } from "../../core/hooks/useTheme.ts"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Protobuf } from "@meshtastic/core"; import { bbox, lineString } from "@turf/turf"; -import { MapPinIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { FunnelIcon, MapPinIcon } from "lucide-react"; +import { useCallback, useDeferredValue, useMemo, useState } from "react"; +import { Marker, Popup, useMap } from "react-map-gl/maplibre"; +import { Map } from "@components/Map.tsx"; import { - AttributionControl, - GeolocateControl, - Marker, - NavigationControl, - Popup, - ScaleControl, - 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 FilterState, + useFilterNode, +} from "@components/generic/Filter/useFilterNode.ts"; +import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; +import { cn } from "@core/utils/cn.ts"; type NodePosition = { latitude: number; longitude: number; }; -const convertToLatLng = (position: { +const convertToLatLng = (position?: { latitudeI?: number; longitudeI?: number; }): NodePosition => ({ - latitude: (position.latitudeI ?? 0) / 1e7, - longitude: (position.longitudeI ?? 0) / 1e7, + latitude: (position?.latitudeI ?? 0) / 1e7, + longitude: (position?.longitudeI ?? 0) / 1e7, }); const MapPage = () => { const { getNodes, waypoints, hasNodeError } = useDevice(); - const { theme } = useTheme(); - const { default: map } = useMap(); + const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); - const darkMode = theme === "dark"; + const { default: map } = useMap(); const [selectedNode, setSelectedNode] = useState< Protobuf.Mesh.NodeInfo | null >(null); - // Filter out nodes without a valid position const validNodes = useMemo( () => getNodes( @@ -55,25 +48,15 @@ const MapPage = () => { [getNodes], ); - const { - filters, - defaultState, - onFilterChange, - resetFilters, - filteredNodes, - groupedFilterConfigs, - } = useNodeFilters(validNodes); - - const isDirty = useMemo(() => { - return Object.keys(filters).some((key) => { - const a = filters[key]; - const b = defaultState[key]; - // simple deep‐equal for primitives and [number,number] - return Array.isArray(a) && Array.isArray(b) - ? a[0] !== b[0] || a[1] !== b[1] - : a !== b; - }); - }, [filters, defaultState]); + const [filterState, setFilterState] = useState(() => + defaultFilterValues + ); + const deferredFilterState = useDeferredValue(filterState); + + const filteredNodes = useMemo( + () => validNodes.filter((node) => nodeFilter(node, deferredFilterState)), + [validNodes, deferredFilterState], + ); const handleMarkerClick = useCallback( (node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => { @@ -94,13 +77,8 @@ const MapPage = () => { // Get the bounds of the map based on the nodes furtherest away from center const getMapBounds = useCallback(() => { - if (!map) { - return; - } + if (!map || validNodes.length === 0) return; - if (!validNodes.length) { - return; - } if (validNodes.length === 1) { map.easeTo({ zoom: map.getZoom(), @@ -111,6 +89,7 @@ const MapPage = () => { }); return; } + const line = lineString( validNodes.map((n) => [ (n.position?.latitudeI ?? 0) / 1e7, @@ -128,7 +107,7 @@ const MapPage = () => { if (center) { map.easeTo(center); } - }, [filteredNodes, map]); + }, [map, validNodes]); // Generate all markers const markers = useMemo( @@ -145,7 +124,7 @@ const MapPage = () => { > @@ -155,45 +134,10 @@ const MapPage = () => { [filteredNodes, handleMarkerClick], ); - useEffect(() => { - map?.on("load", () => { - getMapBounds(); - }); - }, [map, getMapBounds]); - return ( <> }> - - - - - - + {waypoints.map((wp) => ( { ))} {markers} - {selectedNode - ? ( + {selectedNode && (() => { + const position = convertToLatLng(selectedNode.position); + return ( setSelectedNode(null)} className="w-full" > - ) - : null} - + ); + })()} + , + showTextSearch: true, + }} /> diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index 7ce8cc2c..df4bf91c 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -15,11 +15,17 @@ import { useCallback, useDeferredValue, useEffect, + useMemo, useState, } from "react"; import { base16 } from "rfc4648"; import { Input } from "@components/UI/Input.tsx"; import { PageLayout } from "@components/PageLayout.tsx"; +import { + type FilterState, + useFilterNode, +} from "@components/generic/Filter/useFilterNode.ts"; +import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; export interface DeleteNoteDialogProps { open: boolean; @@ -28,6 +34,7 @@ export interface DeleteNoteDialogProps { const NodesPage = (): JSX.Element => { const { getNodes, hardware, connection, hasNodeError } = useDevice(); + const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); const [selectedNode, setSelectedNode] = useState< Protobuf.Mesh.NodeInfo | undefined >(undefined); @@ -37,17 +44,16 @@ const NodesPage = (): JSX.Element => { const [selectedLocation, setSelectedLocation] = useState< Types.PacketMetadata | undefined >(); - const [searchTerm, setSearchTerm] = useState(""); - const deferredSearch = useDeferredValue(searchTerm); - const filteredNodes = getNodes((node) => { - if (!node.user) return false; - const lowerCaseSearchTerm = deferredSearch.toLowerCase(); - return ( - node.user?.longName?.toLowerCase().includes(lowerCaseSearchTerm) || - node.user?.shortName?.toLowerCase().includes(lowerCaseSearchTerm) - ); - }); + const [filterState, setFilterState] = useState(() => + defaultFilterValues + ); + const deferredFilterState = useDeferredValue(filterState); + + const filteredNodes = useMemo( + () => getNodes((node) => nodeFilter(node, deferredFilterState)), + [deferredFilterState, getNodes, nodeFilter], + ); useEffect(() => { if (!connection) return; @@ -79,20 +85,44 @@ const NodesPage = (): JSX.Element => { }, [hardware.myNodeNum], ); + return ( <> } > -
- setSearchTerm(e.target.value)} - /> +
+
+ + setFilterState((prev) => ({ + ...prev, + nodeName: e.target.value, + }))} + /> +
+
+ +