You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
213 lines
6.5 KiB
213 lines
6.5 KiB
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
|
import {
|
|
type FilterState,
|
|
useFilterNode,
|
|
} from "@components/generic/Filter/useFilterNode.ts";
|
|
import { BaseMap } from "@components/Map.tsx";
|
|
import { PageLayout } from "@components/PageLayout.tsx";
|
|
import { Sidebar } from "@components/Sidebar.tsx";
|
|
import { useDevice, useNodeDB } from "@core/stores";
|
|
import { cn } from "@core/utils/cn.ts";
|
|
import type { Protobuf } from "@meshtastic/core";
|
|
import { bbox, lineString } from "@turf/turf";
|
|
import { FunnelIcon, MapPinIcon } from "lucide-react";
|
|
import {
|
|
useCallback,
|
|
useDeferredValue,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
|
|
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
|
|
import { Avatar } from "../../components/UI/Avatar.tsx";
|
|
|
|
const NODEDB_DEBOUNCE_MS = 250;
|
|
|
|
type NodePosition = {
|
|
latitude: number;
|
|
longitude: number;
|
|
};
|
|
|
|
const convertToLatLng = (position?: {
|
|
latitudeI?: number;
|
|
longitudeI?: number;
|
|
}): NodePosition => ({
|
|
latitude: (position?.latitudeI ?? 0) / 1e7,
|
|
longitude: (position?.longitudeI ?? 0) / 1e7,
|
|
});
|
|
|
|
const MapPage = () => {
|
|
const { waypoints } = useDevice();
|
|
const { nodes: validNodes, hasNodeError } = useNodeDB(
|
|
(db) => ({
|
|
// only nodes with a position
|
|
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo =>
|
|
Boolean(n.position?.latitudeI),
|
|
),
|
|
hasNodeError: db.hasNodeError,
|
|
// include the Map reference so error badges update when nodeErrors changes
|
|
_errorsRef: db.nodeErrors,
|
|
}),
|
|
{ debounce: NODEDB_DEBOUNCE_MS },
|
|
);
|
|
|
|
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
|
|
|
const { default: map } = useMap();
|
|
|
|
const [selectedNode, setSelectedNode] =
|
|
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
|
|
|
const [filterState, setFilterState] = useState<FilterState>(
|
|
() => defaultFilterValues,
|
|
);
|
|
const deferredFilterState = useDeferredValue(filterState);
|
|
|
|
const filteredNodes = useMemo(
|
|
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
|
|
[validNodes, deferredFilterState, nodeFilter],
|
|
);
|
|
|
|
const hasFitBoundsOnce = useRef(false);
|
|
|
|
const handleMarkerClick = useCallback(
|
|
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
|
|
event?.originalEvent?.stopPropagation();
|
|
|
|
setSelectedNode(node);
|
|
|
|
if (map) {
|
|
const position = convertToLatLng(node.position);
|
|
map.easeTo({
|
|
center: [position.longitude, position.latitude],
|
|
zoom: map?.getZoom(),
|
|
});
|
|
}
|
|
},
|
|
[map],
|
|
);
|
|
|
|
// Get the bounds of the map based on the nodes furtherest away from center
|
|
const getMapBounds = useCallback(() => {
|
|
if (hasFitBoundsOnce.current || !map || validNodes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (validNodes.length === 1 && validNodes[0]) {
|
|
map.easeTo({
|
|
zoom: map.getZoom(),
|
|
center: [
|
|
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
|
|
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const line = lineString(
|
|
validNodes.map((n) => [
|
|
(n.position?.latitudeI ?? 0) / 1e7,
|
|
(n.position?.longitudeI ?? 0) / 1e7,
|
|
]),
|
|
);
|
|
const bounds = bbox(line);
|
|
const center = map.cameraForBounds(
|
|
[
|
|
[bounds[1], bounds[0]],
|
|
[bounds[3], bounds[2]],
|
|
],
|
|
{ padding: { top: 10, bottom: 10, left: 10, right: 10 } },
|
|
);
|
|
if (center) {
|
|
map.easeTo(center);
|
|
}
|
|
hasFitBoundsOnce.current = true;
|
|
}, [map, validNodes]);
|
|
|
|
// Generate all markers
|
|
const markers = useMemo(
|
|
() =>
|
|
filteredNodes.map((node) => {
|
|
const position = convertToLatLng(node.position);
|
|
return (
|
|
<Marker
|
|
key={`marker-${node.num}`}
|
|
longitude={position.longitude}
|
|
latitude={position.latitude}
|
|
anchor="bottom"
|
|
onClick={(e) => handleMarkerClick(node, e)}
|
|
>
|
|
<Avatar
|
|
text={node.user?.shortName?.toString() ?? node.num.toString()}
|
|
className="border-[1.5px] border-slate-600 shadow-m shadow-slate-600"
|
|
showError={hasNodeError(node.num)}
|
|
showFavorite={node.isFavorite}
|
|
/>
|
|
</Marker>
|
|
);
|
|
}),
|
|
[filteredNodes, handleMarkerClick, hasNodeError],
|
|
);
|
|
|
|
return (
|
|
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
|
|
<BaseMap onLoad={getMapBounds}>
|
|
{waypoints.map((wp) => (
|
|
<Marker
|
|
key={wp.id}
|
|
longitude={(wp.longitudeI ?? 0) / 1e7}
|
|
latitude={(wp.latitudeI ?? 0) / 1e7}
|
|
anchor="bottom"
|
|
>
|
|
<div>
|
|
<MapPinIcon size={16} />
|
|
</div>
|
|
</Marker>
|
|
))}
|
|
{markers}
|
|
{selectedNode &&
|
|
(() => {
|
|
const position = convertToLatLng(selectedNode.position);
|
|
return (
|
|
<Popup
|
|
key={selectedNode.num}
|
|
anchor="top"
|
|
longitude={position.longitude}
|
|
latitude={position.latitude}
|
|
onClose={() => setSelectedNode(null)}
|
|
className="w-full"
|
|
>
|
|
<NodeDetail node={selectedNode} />
|
|
</Popup>
|
|
);
|
|
})()}
|
|
</BaseMap>
|
|
|
|
<FilterControl
|
|
filterState={filterState}
|
|
defaultFilterValues={defaultFilterValues}
|
|
setFilterState={setFilterState}
|
|
isDirty={isFilterDirty(filterState)}
|
|
parameters={{
|
|
popoverContentProps: {
|
|
side: "bottom",
|
|
align: "end",
|
|
sideOffset: 12,
|
|
},
|
|
popoverTriggerClassName: cn(
|
|
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
|
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
|
|
isFilterDirty(filterState)
|
|
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
|
: "",
|
|
),
|
|
triggerIcon: <FunnelIcon className="w-5" />,
|
|
showTextSearch: true,
|
|
}}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
};
|
|
|
|
export default MapPage;
|
|
|