Browse Source

Add map filter groups / more filters / update UI

pull/585/head
Jeremy Gallant 1 year ago
parent
commit
34db0da87c
  1. 145
      src/core/hooks/useNodeFilters.ts
  2. 266
      src/pages/Map/FilterControl.tsx
  3. 4
      src/pages/Map/index.tsx

145
src/core/hooks/useNodeFilters.ts

@ -1,10 +1,11 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import type { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
interface BooleanFilter { interface BooleanFilter {
key: string; key: string;
label: string; label: string;
group: string;
type: "boolean"; type: "boolean";
predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean; predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean;
} }
@ -12,6 +13,7 @@ interface BooleanFilter {
interface RangeFilter { interface RangeFilter {
key: string; key: string;
label: string; label: string;
group: string;
type: "range"; type: "range";
bounds: [number, number]; bounds: [number, number];
predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean; predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean;
@ -20,16 +22,31 @@ interface RangeFilter {
interface SearchFilter { interface SearchFilter {
key: string; key: string;
label: string; label: string;
group: string;
type: "search"; type: "search";
predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean; 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 = { export type FilterValueMap = {
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean [C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean
: C extends RangeFilter ? [number, number] : C extends RangeFilter ? [number, number]
: C extends SearchFilter ? string : C extends SearchFilter ? string
: C extends MultiFilter ? string[]
: never; : never;
}; };
@ -38,6 +55,7 @@ export const filterConfigs: FilterConfig[] = [
{ {
key: "searchText", key: "searchText",
label: "Node name/number", label: "Node name/number",
group: "General",
type: "search", type: "search",
predicate: (node, text: string) => { predicate: (node, text: string) => {
if (!text) return true; if (!text) return true;
@ -51,25 +69,58 @@ export const filterConfigs: FilterConfig[] = [
nodeNumHex.includes(search.replace(/!/g, "")); 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", key: "favOnly",
label: "Show favourites only", label: "Show favourites only",
group: "General",
type: "boolean", type: "boolean",
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite, predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite,
}, },
{ {
key: "hopRange", key: "viaMqtt",
label: "Number of hops", 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", type: "range",
bounds: [0, 7], bounds: [-20, 10],
predicate: (node, [min, max]: [number, number]) => { predicate: (node, [min, max]: [number, number]) => {
const hops = node.hopsAway ?? 7; const snr = node.snr ?? -20;
return hops >= min && hops <= max; return snr >= min && snr <= max;
}, },
}, },
{ {
key: "channelUtilization", key: "channelUtilization",
label: "Channel Utilization (%)", label: "Channel Utilization (%)",
group: "Metrics",
type: "range", type: "range",
bounds: [0, 100], bounds: [0, 100],
predicate: (node, [min, max]: [number, number]) => { predicate: (node, [min, max]: [number, number]) => {
@ -80,6 +131,7 @@ export const filterConfigs: FilterConfig[] = [
{ {
key: "airUtilTx", key: "airUtilTx",
label: "Airtime Utilization (%)", label: "Airtime Utilization (%)",
group: "Metrics",
type: "range", type: "range",
bounds: [0, 100], bounds: [0, 100],
predicate: (node, [min, max]: [number, number]) => { predicate: (node, [min, max]: [number, number]) => {
@ -90,6 +142,7 @@ export const filterConfigs: FilterConfig[] = [
{ {
key: "battery", key: "battery",
label: "Battery level (%)", label: "Battery level (%)",
group: "Metrics",
type: "range", type: "range",
bounds: [0, 101], bounds: [0, 101],
predicate: (node, [min, max]: [number, number]) => { predicate: (node, [min, max]: [number, number]) => {
@ -98,10 +151,52 @@ export const filterConfigs: FilterConfig[] = [
}, },
}, },
{ {
key: "viaMqtt", key: "voltage",
label: "Hide MQTT-connected nodes", label: "Battery voltage (V)",
type: "boolean", group: "Metrics",
predicate: (node, hide: boolean) => !hide || !node.viaMqtt, 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": case "search":
acc[cfg.key] = ""; acc[cfg.key] = "";
break; break;
case "multi":
acc[cfg.key] = cfg.options;
break;
} }
return acc; return acc;
}, {} as FilterValueMap); }, {} as FilterValueMap);
@ -127,6 +225,15 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
defaultState, defaultState,
); );
const groupedFilterConfigs = useMemo(() => {
return filterConfigs.reduce<Record<string, FilterConfig[]>>((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(() => { const resetFilters = useCallback(() => {
setFilters(defaultState); setFilters(defaultState);
}, [defaultState]); }, [defaultState]);
@ -148,7 +255,7 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
if (typeof val !== "boolean") return true; if (typeof val !== "boolean") return true;
return cfg.predicate(node, val); return cfg.predicate(node, val);
case "range": case "range": {
if ( if (
!Array.isArray(val) || !Array.isArray(val) ||
val.length !== 2 || val.length !== 2 ||
@ -157,8 +264,16 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
) { ) {
return true; 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": case "search":
if (typeof val !== "string") return true; if (typeof val !== "string") return true;
return cfg.predicate(node, val); return cfg.predicate(node, val);
@ -174,6 +289,6 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
onFilterChange, onFilterChange,
resetFilters, resetFilters,
filteredNodes, filteredNodes,
filterConfigs, groupedFilterConfigs,
}; };
} }

266
src/pages/Map/FilterControl.tsx

@ -6,14 +6,23 @@ import {
import { FunnelIcon } from "lucide-react"; import { FunnelIcon } from "lucide-react";
import { Checkbox } from "@components/UI/Checkbox/index.tsx"; import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Slider } from "@components/UI/Slider.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 { import type {
FilterConfig, FilterConfig,
FilterValueMap, FilterValueMap,
} from "@core/hooks/useNodeFilters.ts"; } from "@core/hooks/useNodeFilters.ts";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
interface FilterControlProps { interface FilterControlProps {
configs: FilterConfig[]; groupedFilterConfigs: Record<string, FilterConfig[]>;
values: FilterValueMap; values: FilterValueMap;
onChange: <K extends keyof FilterValueMap>( onChange: <K extends keyof FilterValueMap>(
key: K, key: K,
@ -25,7 +34,7 @@ interface FilterControlProps {
} }
export function FilterControl( export function FilterControl(
{ configs, values, onChange, resetFilters, isDirty, children }: { groupedFilterConfigs, values, onChange, resetFilters, isDirty, children }:
FilterControlProps, FilterControlProps,
) { ) {
return ( return (
@ -51,78 +60,191 @@ export function FilterControl(
className="dark:bg-slate-100 dark:border-slate-300" className="dark:bg-slate-100 dark:border-slate-300"
> >
<div className="space-y-4"> <div className="space-y-4">
{configs.map((cfg) => { <Accordion
const val = values[cfg.key]; className="AccordionRoot"
switch (cfg.type) { type="single"
case "boolean": defaultValue={Object.entries(groupedFilterConfigs)[0][0]}
if (typeof val !== "boolean") return null; collapsible
return ( >
<Checkbox {Object.entries(groupedFilterConfigs).map((
key={cfg.key} [groupName, groupConfigs],
checked={val} ) => (
onChange={(v) => onChange(cfg.key, v)} <AccordionItem key={groupName} value={groupName}>
labelClassName="dark:text-gray-900" <AccordionHeader>
> <AccordionTrigger className="w-full text-left font-bold text-sm px-1 py-2">
{cfg.label} {groupName}
</Checkbox> </AccordionTrigger>
); </AccordionHeader>
case "range": { <AccordionContent className="px-1 pb-4 pt-2 space-y-3">
if ( {groupConfigs.map((cfg) => {
!Array.isArray(val) || const val = values[cfg.key];
val.length !== 2 || switch (cfg.type) {
typeof val[0] !== "number" || case "boolean":
typeof val[1] !== "number" if (typeof val !== "boolean") return null;
) { return (
return null; <Checkbox
} key={cfg.key}
const [min, max] = val; checked={val}
const [lo, hi] = cfg.bounds; onChange={(v) => onChange(cfg.key, v)}
return ( className="pb-1"
<div key={cfg.key} className="space-y-2"> labelClassName="dark:text-gray-900"
<label className="block text-sm font-medium"> >
{cfg.label}: {min} {max} {cfg.label}
</label> </Checkbox>
<Slider );
value={[min, max]} case "range": {
min={lo} if (
max={hi} !Array.isArray(val) ||
step={1} val.length !== 2 ||
onValueChange={(newRange) => { typeof val[0] !== "number" ||
const [newMin, newMax] = newRange; typeof val[1] !== "number"
onChange(cfg.key, [newMin, newMax]); ) {
}} return null;
className="w-full" }
trackClassName="h-1 bg-gray-200 dark:bg-slate-700" const [min, max] = val;
rangeClassName="bg-blue-500" const [lo, hi] = cfg.bounds;
thumbClassName="w-3 h-3 bg-white border border-gray-400 dark:border-slate-600"
aria-label={`Slider - ${cfg.label}`} let formattedMin = null;
/> let formattedMax = null;
</div>
); // Some filters require special formatting for min/max values
} if (cfg.key == "battery" && min == hi) {
case "search": formattedMin = "Charging";
if (typeof val !== "string") return null; }
return ( if (cfg.key == "battery" && max == hi) {
<div key={cfg.key} className="flex flex-col space-y-1"> formattedMax = "Charging";
<label htmlFor={cfg.key} className="font-medium text-sm"> }
{cfg.label} if (cfg.key == "hopRange" && min == lo) {
</label> formattedMin = "Direct";
<input }
id={cfg.key} if (cfg.key == "lastHeard") {
type="text" formattedMin = (
value={val} <>
onChange={(e) => onChange(cfg.key, e.target.value)} <br />
placeholder="Search phrase" {min === lo ? "now" : (
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600" <TimeAgo
/> timestamp={Date.now() - min * 1000}
</div> />
); )}
</>
);
formattedMax = (
<>
{max === hi ? ">" : ""}
<TimeAgo timestamp={Date.now() - max * 1000} />
</>
);
}
return (
<div key={cfg.key} className="space-y-2">
<label className="block text-sm font-medium">
{cfg.label}:{" "}
{min === max ? formattedMin ?? min : (
<>
{formattedMin ?? min} {formattedMax ?? max}
</>
)}
</label>
<Slider
value={[min, max]}
min={lo}
max={hi}
step={Number.isInteger(lo) ? 1 : 0.1}
onValueChange={(newRange) => {
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}`}
/>
</div>
);
}
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));
default: return (
return null; <ScrollArea className="h-64 border rounded-md">
} <div
})} key={cfg.key}
className="space-y-2 px-2 py-3"
>
<button
type="button"
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-800"
onClick={() =>
onChange(
cfg.key,
allSelected ? [] : [...cfg.options],
)}
>
{allSelected ? "Uncheck All" : "Check All"}
</button>
{cfg.options.map((opt) => (
<Checkbox
key={opt.replace(/ /g, "_")}
checked={safeArray.includes(opt)}
onChange={(checked) =>
onChange(
cfg.key,
checked
? [...safeArray, opt]
: safeArray.filter((s) => s !== opt),
)}
>
{opt}
</Checkbox>
))}
</div>
</ScrollArea>
);
}
case "search":
if (typeof val !== "string") return null;
return (
<div
key={`${cfg.key}_div`}
className="flex flex-col space-y-1 pb-2"
>
<label
htmlFor={cfg.key}
className="font-medium text-sm"
>
{cfg.label}
</label>
<input
id={cfg.key}
type="text"
value={val}
onChange={(e) =>
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"
/>
</div>
);
default:
return null;
}
})}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<button <button
type="button" type="button"
onClick={resetFilters} onClick={resetFilters}

4
src/pages/Map/index.tsx

@ -61,7 +61,7 @@ const MapPage = () => {
onFilterChange, onFilterChange,
resetFilters, resetFilters,
filteredNodes, filteredNodes,
filterConfigs, groupedFilterConfigs,
} = useNodeFilters(validNodes); } = useNodeFilters(validNodes);
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
@ -221,7 +221,7 @@ const MapPage = () => {
</MapGl> </MapGl>
<FilterControl <FilterControl
configs={filterConfigs} groupedFilterConfigs={groupedFilterConfigs}
values={filters} values={filters}
onChange={onFilterChange} onChange={onFilterChange}
resetFilters={resetFilters} resetFilters={resetFilters}

Loading…
Cancel
Save