|
|
|
@ -1,59 +1,93 @@ |
|
|
|
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail"; |
|
|
|
import { Avatar } from "@app/components/UI/Avatar"; |
|
|
|
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; |
|
|
|
import { cn } from "@app/core/utils/cn.ts"; |
|
|
|
import { PageLayout } from "@components/PageLayout.tsx"; |
|
|
|
import { Sidebar } from "@components/Sidebar.tsx"; |
|
|
|
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; |
|
|
|
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; |
|
|
|
import { useAppStore } from "@core/stores/appStore.ts"; |
|
|
|
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
|
|
import type { Protobuf } from "@meshtastic/js"; |
|
|
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|
|
|
import { bbox, lineString } from "@turf/turf"; |
|
|
|
import { |
|
|
|
BoxSelectIcon, |
|
|
|
MapPinIcon, |
|
|
|
ZoomInIcon, |
|
|
|
ZoomOutIcon, |
|
|
|
} from "lucide-react"; |
|
|
|
import { MapPinIcon } from "lucide-react"; |
|
|
|
import { type JSX, useCallback, useEffect, useMemo, useState } from "react"; |
|
|
|
import { AttributionControl, Marker, Popup, useMap } from "react-map-gl"; |
|
|
|
import { |
|
|
|
AttributionControl, |
|
|
|
GeolocateControl, |
|
|
|
Marker, |
|
|
|
NavigationControl, |
|
|
|
Popup, |
|
|
|
ScaleControl, |
|
|
|
useMap, |
|
|
|
} from "react-map-gl"; |
|
|
|
import MapGl from "react-map-gl/maplibre"; |
|
|
|
|
|
|
|
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 = (): JSX.Element => { |
|
|
|
const { nodes, waypoints } = useDevice(); |
|
|
|
const { rasterSources, darkMode } = useAppStore(); |
|
|
|
const { darkMode } = useAppStore(); |
|
|
|
const { default: map } = useMap(); |
|
|
|
|
|
|
|
const [zoom, setZoom] = useState(0); |
|
|
|
const [selectedNode, setSelectedNode] = |
|
|
|
useState<Protobuf.Mesh.NodeInfo | null>(null); |
|
|
|
|
|
|
|
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]); |
|
|
|
// Filter out nodes without a valid position
|
|
|
|
const validNodes = useMemo( |
|
|
|
() => |
|
|
|
Array.from(nodes.values()).filter( |
|
|
|
(node): node is Protobuf.Mesh.NodeInfo => |
|
|
|
Boolean(node.position?.latitudeI), |
|
|
|
), |
|
|
|
[nodes], |
|
|
|
); |
|
|
|
|
|
|
|
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], |
|
|
|
); |
|
|
|
|
|
|
|
const getBBox = useCallback(() => { |
|
|
|
// Get the bounds of the map based on the nodes furtherest away from center
|
|
|
|
const getMapBounds = useCallback(() => { |
|
|
|
if (!map) { |
|
|
|
return; |
|
|
|
} |
|
|
|
const nodesWithPosition = allNodes.filter( |
|
|
|
(node) => node.position?.latitudeI, |
|
|
|
); |
|
|
|
if (!nodesWithPosition.length) { |
|
|
|
|
|
|
|
if (!validNodes.length) { |
|
|
|
return; |
|
|
|
} |
|
|
|
if (nodesWithPosition.length === 1) { |
|
|
|
if (validNodes.length === 1) { |
|
|
|
map.easeTo({ |
|
|
|
zoom: 12, |
|
|
|
zoom: map.getZoom(), |
|
|
|
center: [ |
|
|
|
(nodesWithPosition[0].position?.longitudeI ?? 0) / 1e7, |
|
|
|
(nodesWithPosition[0].position?.latitudeI ?? 0) / 1e7, |
|
|
|
(validNodes[0].position?.longitudeI ?? 0) / 1e7, |
|
|
|
(validNodes[0].position?.latitudeI ?? 0) / 1e7, |
|
|
|
], |
|
|
|
}); |
|
|
|
return; |
|
|
|
} |
|
|
|
const line = lineString( |
|
|
|
nodesWithPosition.map((n) => [ |
|
|
|
validNodes.map((n) => [ |
|
|
|
(n.position?.latitudeI ?? 0) / 1e7, |
|
|
|
(n.position?.longitudeI ?? 0) / 1e7, |
|
|
|
]), |
|
|
|
@ -69,78 +103,54 @@ const MapPage = (): JSX.Element => { |
|
|
|
if (center) { |
|
|
|
map.easeTo(center); |
|
|
|
} |
|
|
|
}, [allNodes, map]); |
|
|
|
}, [validNodes, map]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
map?.on("zoom", () => { |
|
|
|
setZoom(map?.getZoom() ?? 0); |
|
|
|
}); |
|
|
|
}, [map]); |
|
|
|
// Generate all markers
|
|
|
|
const markers = useMemo( |
|
|
|
() => |
|
|
|
validNodes.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-xl shadow-slate-600" |
|
|
|
/> |
|
|
|
</Marker> |
|
|
|
); |
|
|
|
}), |
|
|
|
[validNodes, handleMarkerClick], |
|
|
|
); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
map?.on("load", () => { |
|
|
|
getBBox(); |
|
|
|
getMapBounds(); |
|
|
|
}); |
|
|
|
}, [map, getBBox]); |
|
|
|
}, [map, getMapBounds]); |
|
|
|
|
|
|
|
return ( |
|
|
|
<> |
|
|
|
<Sidebar> |
|
|
|
<SidebarSection label="Sources"> |
|
|
|
{rasterSources.map((source) => ( |
|
|
|
<SidebarButton key={source.title} label={source.title} /> |
|
|
|
))} |
|
|
|
</SidebarSection> |
|
|
|
</Sidebar> |
|
|
|
<PageLayout |
|
|
|
label="Map" |
|
|
|
noPadding={true} |
|
|
|
actions={[ |
|
|
|
{ |
|
|
|
icon: ZoomInIcon, |
|
|
|
onClick() { |
|
|
|
map?.zoomIn(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
icon: ZoomOutIcon, |
|
|
|
onClick() { |
|
|
|
map?.zoomOut(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
icon: BoxSelectIcon, |
|
|
|
onClick() { |
|
|
|
getBBox(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
]} |
|
|
|
> |
|
|
|
<Sidebar /> |
|
|
|
<PageLayout label="Map" noPadding={true} actions={[]}> |
|
|
|
<MapGl |
|
|
|
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json" |
|
|
|
// onClick={(e) => {
|
|
|
|
// const waypoint = new Protobuf.Waypoint({
|
|
|
|
// name: "test",
|
|
|
|
// description: "test description",
|
|
|
|
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
|
|
|
|
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
|
|
|
|
// });
|
|
|
|
// addWaypoint(waypoint);
|
|
|
|
// connection?.sendWaypoint(waypoint, "broadcast");
|
|
|
|
// }}
|
|
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
|
|
attributionControl={false} |
|
|
|
renderWorldCopies={false} |
|
|
|
maxPitch={0} |
|
|
|
antialias={true} |
|
|
|
style={{ |
|
|
|
filter: darkMode ? "brightness(0.8)" : "", |
|
|
|
filter: darkMode ? "brightness(0.9)" : "", |
|
|
|
}} |
|
|
|
dragRotate={false} |
|
|
|
touchZoomRotate={false} |
|
|
|
initialViewState={{ |
|
|
|
zoom: 1.6, |
|
|
|
zoom: 1.8, |
|
|
|
latitude: 35, |
|
|
|
longitude: 0, |
|
|
|
}} |
|
|
|
@ -151,6 +161,14 @@ const MapPage = (): JSX.Element => { |
|
|
|
color: darkMode ? "black" : "", |
|
|
|
}} |
|
|
|
/> |
|
|
|
<GeolocateControl |
|
|
|
position="top-right" |
|
|
|
positionOptions={{ enableHighAccuracy: true }} |
|
|
|
trackUserLocation |
|
|
|
/> |
|
|
|
<NavigationControl position="top-right" showCompass={false} /> |
|
|
|
|
|
|
|
<ScaleControl /> |
|
|
|
{waypoints.map((wp) => ( |
|
|
|
<Marker |
|
|
|
key={wp.id} |
|
|
|
@ -163,58 +181,17 @@ const MapPage = (): JSX.Element => { |
|
|
|
</div> |
|
|
|
</Marker> |
|
|
|
))} |
|
|
|
{/* {rasterSources.map((source, index) => ( |
|
|
|
<Source key={index} type="raster" {...source}> |
|
|
|
<Layer type="raster" /> |
|
|
|
</Source> |
|
|
|
))} */} |
|
|
|
{allNodes.map((node) => { |
|
|
|
if (node.position?.latitudeI && node.num !== selectedNode?.num) { |
|
|
|
return ( |
|
|
|
<Marker |
|
|
|
key={node.num} |
|
|
|
longitude={(node.position.longitudeI ?? 0) / 1e7} |
|
|
|
latitude={(node.position.latitudeI ?? 0) / 1e7} |
|
|
|
// style={{ filter: darkMode ? "invert(1)" : "" }}
|
|
|
|
anchor="bottom" |
|
|
|
onClick={() => { |
|
|
|
setSelectedNode(node); |
|
|
|
map?.easeTo({ |
|
|
|
zoom: 12, |
|
|
|
center: [ |
|
|
|
(node.position?.longitudeI ?? 0) / 1e7, |
|
|
|
(node.position?.latitudeI ?? 0) / 1e7, |
|
|
|
], |
|
|
|
}); |
|
|
|
}} |
|
|
|
> |
|
|
|
<div className="flex cursor-pointer gap-2 rounded-md bg-transparent p-1.5"> |
|
|
|
<Avatar |
|
|
|
text={ |
|
|
|
node.user?.shortName.toString() ?? node.num.toString() |
|
|
|
} |
|
|
|
size="sm" |
|
|
|
/> |
|
|
|
<Subtle className={cn(zoom < 12 && "hidden")}> |
|
|
|
{node.user?.longName || |
|
|
|
`!${numberToHexUnpadded(node.num)}`} |
|
|
|
</Subtle> |
|
|
|
</div> |
|
|
|
</Marker> |
|
|
|
); |
|
|
|
} |
|
|
|
})} |
|
|
|
{selectedNode?.position && ( |
|
|
|
{markers} |
|
|
|
{selectedNode ? ( |
|
|
|
<Popup |
|
|
|
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7} |
|
|
|
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7} |
|
|
|
anchor="left" |
|
|
|
closeOnClick={false} |
|
|
|
anchor="top" |
|
|
|
longitude={convertToLatLng(selectedNode.position).longitude} |
|
|
|
latitude={convertToLatLng(selectedNode.position).latitude} |
|
|
|
onClose={() => setSelectedNode(null)} |
|
|
|
> |
|
|
|
<NodeDetail node={selectedNode} /> |
|
|
|
</Popup> |
|
|
|
)} |
|
|
|
) : null} |
|
|
|
</MapGl> |
|
|
|
</PageLayout> |
|
|
|
</> |
|
|
|
|