Add filters to node map
This commit is contained in:
@@ -51,6 +51,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
"@radix-ui/react-slider": "^1.3.2",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useId } from "react";
|
import { useState, useEffect, useId } from "react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { Label } from "@components/UI/Label.tsx";
|
import { Label } from "@components/UI/Label.tsx";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
@@ -32,6 +32,11 @@ export function Checkbox({
|
|||||||
|
|
||||||
const [isChecked, setIsChecked] = useState(checked || false);
|
const [isChecked, setIsChecked] = useState(checked || false);
|
||||||
|
|
||||||
|
// Make sure setIsChecked state updates with checked
|
||||||
|
useEffect(() => {
|
||||||
|
setIsChecked(checked || false);
|
||||||
|
}, [checked]);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
|
export interface SliderProps {
|
||||||
|
value?: number[];
|
||||||
|
defaultValue?: number[];
|
||||||
|
step?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
onValueChange?: (value: number[]) => void;
|
||||||
|
onValueCommit?: (value: number[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
trackClassName?: string;
|
||||||
|
rangeClassName?: string;
|
||||||
|
thumbClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Slider({
|
||||||
|
value,
|
||||||
|
defaultValue = [0],
|
||||||
|
step = 1,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
onValueChange,
|
||||||
|
onValueCommit,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
trackClassName,
|
||||||
|
rangeClassName,
|
||||||
|
thumbClassName,
|
||||||
|
}:SliderProps) {
|
||||||
|
const [internalValue, setInternalValue] = useState<number[]>(defaultValue);
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const currentValue = isControlled ? value! : internalValue;
|
||||||
|
|
||||||
|
const handleValueChange = (newValue: number[]) => {
|
||||||
|
if (!isControlled) setInternalValue(newValue);
|
||||||
|
onValueChange?.(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValueCommit = (newValue: number[]) => {
|
||||||
|
onValueCommit?.(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
className={cn("relative flex items-center select-none touch-none", className)}
|
||||||
|
value={currentValue}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
onValueCommit={handleValueCommit}
|
||||||
|
aria-label="Slider"
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
className={cn("relative h-2 flex-1 rounded-full bg-gray-200", trackClassName)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
className={cn("absolute h-full rounded-full bg-blue-500", rangeClassName)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{currentValue.map((_, i) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"block w-4 h-4 rounded-full bg-white border border-gray-400 shadow-md",
|
||||||
|
thumbClassName
|
||||||
|
)}
|
||||||
|
aria-label={`Thumb ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
|
|
||||||
|
export type FilterValue =
|
||||||
|
| boolean
|
||||||
|
| [number, number]
|
||||||
|
| string[]
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export interface FilterConfig<T extends FilterValue = FilterValue> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "boolean" | "range" | "search";
|
||||||
|
bounds?: [number, number];
|
||||||
|
options?: string[];
|
||||||
|
predicate: (node: Protobuf.Mesh.NodeInfo, value: T) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines all node filters in this object
|
||||||
|
export const filterConfigs: FilterConfig[] = [
|
||||||
|
{
|
||||||
|
key: "searchText",
|
||||||
|
label: "Node name/number",
|
||||||
|
type: "search",
|
||||||
|
predicate: (node, text: string) => {
|
||||||
|
if (!text) return true;
|
||||||
|
const shortName = node.user?.shortName?.toString().toLowerCase() ?? "";
|
||||||
|
const longName = node.user?.longName?.toString().toLowerCase() ?? "";
|
||||||
|
const nodeNum = node.num?.toString() ?? "";
|
||||||
|
const nodeNumHex = numberToHexUnpadded(node.num) ?? "";
|
||||||
|
const search = text.toLowerCase();
|
||||||
|
return shortName.includes(search) || longName.includes(search) || nodeNum.includes(search) || nodeNumHex.includes(search.replace(/!/g, ""));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "favOnly",
|
||||||
|
label: "Show favourites only",
|
||||||
|
type: "boolean",
|
||||||
|
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hopRange",
|
||||||
|
label: "Number of hops",
|
||||||
|
type: "range",
|
||||||
|
bounds: [0, 7],
|
||||||
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
|
const hops = node.hopsAway ?? 7;
|
||||||
|
return hops >= min && hops <= max;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "channelUtilization",
|
||||||
|
label: "Channel Utilization (%)",
|
||||||
|
type: "range",
|
||||||
|
bounds: [0, 100],
|
||||||
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
|
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
|
||||||
|
return channelUtilization >= min && channelUtilization <= max;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "airUtilTx",
|
||||||
|
label: "Airtime Utilization (%)",
|
||||||
|
type: "range",
|
||||||
|
bounds: [0, 100],
|
||||||
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
|
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
|
||||||
|
return airUtilTx >= min && airUtilTx <= max;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "battery",
|
||||||
|
label: "Battery level (%)",
|
||||||
|
type: "range",
|
||||||
|
bounds: [0, 101],
|
||||||
|
predicate: (node, [min, max]: [number, number]) => {
|
||||||
|
const batt = node.deviceMetrics?.batteryLevel ?? 101;
|
||||||
|
return batt >= min && batt <= max;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "viaMqtt",
|
||||||
|
label: "Hide MQTT-connected nodes",
|
||||||
|
type: "boolean",
|
||||||
|
predicate: (node, hide: boolean) => !hide || !node.viaMqtt,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
||||||
|
const defaultState = useMemo<Record<string, FilterValue>>(() => {
|
||||||
|
return filterConfigs.reduce((acc, cfg) => {
|
||||||
|
switch (cfg.type) {
|
||||||
|
case "boolean":
|
||||||
|
acc[cfg.key] = false;
|
||||||
|
break;
|
||||||
|
case "range":
|
||||||
|
acc[cfg.key] = cfg.bounds!;
|
||||||
|
break;
|
||||||
|
case "search":
|
||||||
|
acc[cfg.key] = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, FilterValue>);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<Record<string, FilterValue>>(defaultState);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setFilters(defaultState);
|
||||||
|
}, [defaultState]);
|
||||||
|
|
||||||
|
const onFilterChange = useCallback(
|
||||||
|
(key: string, value: FilterValue) => {
|
||||||
|
setFilters((f) => ({ ...f, [key]: value }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
nodes.filter((node) =>
|
||||||
|
filterConfigs.every((cfg) =>
|
||||||
|
cfg.predicate(node, filters[cfg.key])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[nodes, filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { filters, onFilterChange, resetFilters, filteredNodes, filterConfigs };
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Popover, PopoverTrigger, PopoverContent } from "@components/UI/Popover.tsx";
|
||||||
|
import { FunnelIcon } from "lucide-react";
|
||||||
|
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||||
|
import { Slider } from "@components/UI/Slider.tsx";
|
||||||
|
import type { FilterConfig, FilterValue } from "@core/hooks/useNodeFilters.ts";
|
||||||
|
|
||||||
|
interface FilterControlProps {
|
||||||
|
configs: FilterConfig[];
|
||||||
|
values: Record<string, FilterValue>;
|
||||||
|
onChange: (key: string, value: FilterValue) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterControl({ configs, values, onChange, resetFilters, children }: FilterControlProps) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed bottom-17 right-2 px-1 py-1 bg-slate-100 text-slate-600 rounded shadow-md"
|
||||||
|
aria-label="Filter"
|
||||||
|
>
|
||||||
|
<FunnelIcon />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="bottom" align="end" sideOffset={12} className="dark:bg-slate-100 dark:border-slate-300">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{configs.map((cfg) => {
|
||||||
|
const val = values[cfg.key];
|
||||||
|
switch (cfg.type) {
|
||||||
|
case "boolean":
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
key={cfg.key}
|
||||||
|
checked={val as boolean}
|
||||||
|
onChange={(v) => onChange(cfg.key, v as boolean)}
|
||||||
|
labelClassName="dark:text-gray-900"
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
case "range": {
|
||||||
|
const [min, max] = val as [number, number];
|
||||||
|
const [lo, hi] = cfg.bounds!;
|
||||||
|
return (
|
||||||
|
<div key={cfg.key} className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
{cfg.label}: {min} – {max}
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[min, max]}
|
||||||
|
min={lo}
|
||||||
|
max={hi}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(newRange) =>
|
||||||
|
onChange(cfg.key, newRange as [number, number])
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
trackClassName="h-1 bg-gray-200 dark:bg-slate-700"
|
||||||
|
rangeClassName="bg-blue-500"
|
||||||
|
thumbClassName="w-3 h-3 bg-white border border-gray-400 dark:border-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "search":
|
||||||
|
return (
|
||||||
|
<div key={cfg.key} className="flex flex-col space-y-1">
|
||||||
|
<label htmlFor={cfg.key} className="font-medium text-sm">
|
||||||
|
{cfg.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={cfg.key}
|
||||||
|
type="text"
|
||||||
|
value={val as string}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="w-full py-1 bg-slate-600 text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<div className="mt-4 border-t pt-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
-3
@@ -18,6 +18,8 @@ import {
|
|||||||
useMap,
|
useMap,
|
||||||
} from "react-map-gl/maplibre";
|
} from "react-map-gl/maplibre";
|
||||||
import MapGl from "react-map-gl/maplibre";
|
import MapGl from "react-map-gl/maplibre";
|
||||||
|
import { useNodeFilters } from "@core/hooks/useNodeFilters.ts";
|
||||||
|
import { FilterControl } from "@pages/Map/FilterControl.tsx";
|
||||||
|
|
||||||
type NodePosition = {
|
type NodePosition = {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
@@ -53,6 +55,14 @@ const MapPage = () => {
|
|||||||
[nodes],
|
[nodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
filteredNodes,
|
||||||
|
filters,
|
||||||
|
onFilterChange,
|
||||||
|
resetFilters,
|
||||||
|
filterConfigs,
|
||||||
|
} = useNodeFilters(validNodes);
|
||||||
|
|
||||||
const handleMarkerClick = useCallback(
|
const handleMarkerClick = useCallback(
|
||||||
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
||||||
event?.originalEvent?.stopPropagation();
|
event?.originalEvent?.stopPropagation();
|
||||||
@@ -106,12 +116,12 @@ const MapPage = () => {
|
|||||||
if (center) {
|
if (center) {
|
||||||
map.easeTo(center);
|
map.easeTo(center);
|
||||||
}
|
}
|
||||||
}, [validNodes, map]);
|
}, [filteredNodes, map]);
|
||||||
|
|
||||||
// Generate all markers
|
// Generate all markers
|
||||||
const markers = useMemo(
|
const markers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
validNodes.map((node) => {
|
filteredNodes.map((node) => {
|
||||||
const position = convertToLatLng(node.position);
|
const position = convertToLatLng(node.position);
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
@@ -128,7 +138,7 @@ const MapPage = () => {
|
|||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
[validNodes, handleMarkerClick],
|
[filteredNodes, handleMarkerClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -197,6 +207,14 @@ const MapPage = () => {
|
|||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</MapGl>
|
</MapGl>
|
||||||
|
|
||||||
|
<FilterControl
|
||||||
|
configs={filterConfigs}
|
||||||
|
values={filters}
|
||||||
|
onChange={onFilterChange}
|
||||||
|
resetFilters={resetFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user