From 34db0da87c8dfb7e47b9cd0441cf2c733b611a40 Mon Sep 17 00:00:00 2001 From: Jeremy Gallant Date: Mon, 28 Apr 2025 17:38:58 +0200 Subject: [PATCH] Add map filter groups / more filters / update UI --- src/core/hooks/useNodeFilters.ts | 147 +++++++++++++++-- src/pages/Map/FilterControl.tsx | 266 ++++++++++++++++++++++--------- src/pages/Map/index.tsx | 4 +- 3 files changed, 327 insertions(+), 90 deletions(-) diff --git a/src/core/hooks/useNodeFilters.ts b/src/core/hooks/useNodeFilters.ts index 344b924f..529a2773 100644 --- a/src/core/hooks/useNodeFilters.ts +++ b/src/core/hooks/useNodeFilters.ts @@ -1,10 +1,11 @@ import { useCallback, useMemo, useState } from "react"; -import type { Protobuf } from "@meshtastic/core"; +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; } @@ -12,6 +13,7 @@ interface BooleanFilter { interface RangeFilter { key: string; label: string; + group: string; type: "range"; bounds: [number, number]; predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean; @@ -20,16 +22,31 @@ interface RangeFilter { interface SearchFilter { key: string; label: string; + group: string; type: "search"; predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean; } -export type FilterConfig = BooleanFilter | RangeFilter | SearchFilter; +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; }; @@ -38,6 +55,7 @@ export const filterConfigs: FilterConfig[] = [ { key: "searchText", label: "Node name/number", + group: "General", type: "search", predicate: (node, text: string) => { if (!text) return true; @@ -51,15 +69,10 @@ export const filterConfigs: FilterConfig[] = [ 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", + group: "General", type: "range", bounds: [0, 7], predicate: (node, [min, max]: [number, number]) => { @@ -67,9 +80,47 @@ export const filterConfigs: FilterConfig[] = [ 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]) => { @@ -80,6 +131,7 @@ export const filterConfigs: FilterConfig[] = [ { key: "airUtilTx", label: "Airtime Utilization (%)", + group: "Metrics", type: "range", bounds: [0, 100], predicate: (node, [min, max]: [number, number]) => { @@ -90,6 +142,7 @@ export const filterConfigs: FilterConfig[] = [ { key: "battery", label: "Battery level (%)", + group: "Metrics", type: "range", bounds: [0, 101], predicate: (node, [min, max]: [number, number]) => { @@ -98,10 +151,52 @@ export const filterConfigs: FilterConfig[] = [ }, }, { - key: "viaMqtt", - label: "Hide MQTT-connected nodes", - type: "boolean", - predicate: (node, hide: boolean) => !hide || !node.viaMqtt, + 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]); + }, }, ]; @@ -118,6 +213,9 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { case "search": acc[cfg.key] = ""; break; + case "multi": + acc[cfg.key] = cfg.options; + break; } return acc; }, {} as FilterValueMap); @@ -127,6 +225,15 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { 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]); @@ -148,7 +255,7 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { if (typeof val !== "boolean") return true; return cfg.predicate(node, val); - case "range": + case "range": { if ( !Array.isArray(val) || val.length !== 2 || @@ -157,8 +264,16 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { ) { return true; } - return cfg.predicate(node, val); - + 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); @@ -174,6 +289,6 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { onFilterChange, resetFilters, filteredNodes, - filterConfigs, + groupedFilterConfigs, }; } diff --git a/src/pages/Map/FilterControl.tsx b/src/pages/Map/FilterControl.tsx index 3e4f26d3..b90772ea 100644 --- a/src/pages/Map/FilterControl.tsx +++ b/src/pages/Map/FilterControl.tsx @@ -6,14 +6,23 @@ import { 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 { - configs: FilterConfig[]; + groupedFilterConfigs: Record; values: FilterValueMap; onChange: ( key: K, @@ -25,7 +34,7 @@ interface FilterControlProps { } export function FilterControl( - { configs, values, onChange, resetFilters, isDirty, children }: + { groupedFilterConfigs, values, onChange, resetFilters, isDirty, children }: FilterControlProps, ) { return ( @@ -51,78 +60,191 @@ export function FilterControl( className="dark:bg-slate-100 dark:border-slate-300" >
- {configs.map((cfg) => { - const val = values[cfg.key]; - switch (cfg.type) { - case "boolean": - if (typeof val !== "boolean") return null; - return ( - onChange(cfg.key, v)} - labelClassName="dark:text-gray-900" - > - {cfg.label} - - ); - case "range": { - if ( - !Array.isArray(val) || - val.length !== 2 || - typeof val[0] !== "number" || - typeof val[1] !== "number" - ) { - return null; - } - const [min, max] = val; - const [lo, hi] = cfg.bounds; - return ( -
- - { - const [newMin, newMax] = newRange; - onChange(cfg.key, [newMin, newMax]); - }} - className="w-full" - trackClassName="h-1 bg-gray-200 dark:bg-slate-700" - rangeClassName="bg-blue-500" - thumbClassName="w-3 h-3 bg-white border border-gray-400 dark:border-slate-600" - aria-label={`Slider - ${cfg.label}`} - /> -
- ); - } - case "search": - if (typeof val !== "string") return null; - return ( -
- - onChange(cfg.key, e.target.value)} - placeholder="Search phrase" - className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600" - /> -
- ); + + {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-gray-900" + > + {cfg.label} + + ); + case "range": { + if ( + !Array.isArray(val) || + val.length !== 2 || + typeof val[0] !== "number" || + typeof val[1] !== "number" + ) { + return null; + } + const [min, max] = val; + const [lo, hi] = cfg.bounds; - default: - return null; - } - })} + 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-gray-200 dark:bg-slate-700" + rangeClassName="bg-blue-500" + thumbClassName="w-3 h-3 bg-white border border-gray-400 dark:border-slate-600" + aria-label={`Slider - ${cfg.label}`} + /> +
+ ); + } + case "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; + } + })} +
+
+ ))} +