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, + }))} + /> +
+
+ +