44 changed files with 631 additions and 1084 deletions
File diff suppressed because it is too large
@ -0,0 +1,100 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui"; |
|||
import { Controller, useForm, useWatch } from "react-hook-form"; |
|||
|
|||
import { BluetoothValidation } from "@app/validation/config/bluetooth.js"; |
|||
import { Form } from "@components/form/Form"; |
|||
import { useDevice } from "@core/providers/useDevice.js"; |
|||
import { renderOptions } from "@core/utils/selectEnumOptions.js"; |
|||
import { classValidatorResolver } from "@hookform/resolvers/class-validator"; |
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export const Bluetooth = (): JSX.Element => { |
|||
const { config, connection } = useDevice(); |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const { |
|||
register, |
|||
handleSubmit, |
|||
formState: { errors, isDirty }, |
|||
control, |
|||
reset, |
|||
} = useForm<BluetoothValidation>({ |
|||
defaultValues: config.bluetooth, |
|||
resolver: classValidatorResolver(BluetoothValidation), |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
reset(config.bluetooth); |
|||
}, [reset, config.bluetooth]); |
|||
|
|||
const onSubmit = handleSubmit((data) => { |
|||
setLoading(true); |
|||
void connection?.setConfig( |
|||
{ |
|||
payloadVariant: { |
|||
oneofKind: "bluetooth", |
|||
bluetooth: data, |
|||
}, |
|||
}, |
|||
async () => { |
|||
reset({ ...data }); |
|||
setLoading(false); |
|||
await Promise.resolve(); |
|||
} |
|||
); |
|||
}); |
|||
|
|||
const pairingMode = useWatch({ |
|||
control, |
|||
name: "mode", |
|||
defaultValue: Protobuf.Config_BluetoothConfig_PairingMode.RandomPin, |
|||
}); |
|||
|
|||
return ( |
|||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}> |
|||
<FormField |
|||
label="Bluetooth Enabled" |
|||
description="Description" |
|||
isInvalid={!!errors.enabled?.message} |
|||
validationMessage={errors.enabled?.message} |
|||
> |
|||
<Controller |
|||
name="enabled" |
|||
control={control} |
|||
render={({ field: { value, ...field } }) => ( |
|||
<Switch height={24} marginLeft="auto" checked={value} {...field} /> |
|||
)} |
|||
/> |
|||
</FormField> |
|||
|
|||
<SelectField |
|||
label="Pairing mode" |
|||
description="This is a description." |
|||
isInvalid={!!errors.mode?.message} |
|||
validationMessage={errors.mode?.message} |
|||
{...register("mode", { valueAsNumber: true })} |
|||
> |
|||
{renderOptions(Protobuf.Config_BluetoothConfig_PairingMode)} |
|||
</SelectField> |
|||
|
|||
<TextInputField |
|||
display={ |
|||
pairingMode !== Protobuf.Config_BluetoothConfig_PairingMode.FixedPin |
|||
? "none" |
|||
: "block" |
|||
} |
|||
label="Pin" |
|||
description="This is a description." |
|||
type="number" |
|||
isInvalid={!!errors.fixedPin?.message} |
|||
validationMessage={errors.fixedPin?.message} |
|||
{...register("fixedPin", { |
|||
valueAsNumber: true, |
|||
})} |
|||
/> |
|||
</Form> |
|||
); |
|||
}; |
|||
@ -0,0 +1,32 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
|
|||
import { Map, MapOptions } from "maplibre-gl"; |
|||
|
|||
export interface useMapboxProps { |
|||
ref: React.RefObject<HTMLDivElement>; |
|||
style: string; |
|||
options?: Partial<MapOptions>; |
|||
} |
|||
|
|||
export function useCreateMapbox({ |
|||
ref, |
|||
style, |
|||
options, |
|||
}: useMapboxProps): Map | undefined { |
|||
const [mapInstance, setMapInstance] = useState<Map>(); |
|||
useEffect(() => { |
|||
const container = ref.current as HTMLDivElement; |
|||
if (mapInstance || !container) { |
|||
return; |
|||
} |
|||
const map = new Map({ |
|||
container, |
|||
style, |
|||
...options, |
|||
}); |
|||
setMapInstance(map); |
|||
}, []); |
|||
|
|||
return mapInstance; |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
import { createContext, useContext } from "react"; |
|||
|
|||
import type { Device } from "../stores/deviceStore.js"; |
|||
|
|||
export const DeviceContext = createContext<Device | undefined>(undefined); |
|||
|
|||
export const useDevice = (): Device => { |
|||
const context = useContext(DeviceContext); |
|||
if (context === undefined) { |
|||
throw new Error("useDevice must be used within a ConnectionProvider"); |
|||
} |
|||
return context; |
|||
}; |
|||
@ -0,0 +1,14 @@ |
|||
import { createContext, useContext } from "react"; |
|||
|
|||
import type { Map } from "maplibre-gl"; |
|||
|
|||
export interface MapContextValue { |
|||
ref: React.Ref<HTMLDivElement>; |
|||
map?: Map; |
|||
} |
|||
|
|||
export const MapContext = createContext<MapContextValue>({} as MapContextValue); |
|||
|
|||
export const useMap = (): MapContextValue => { |
|||
return useContext(MapContext); |
|||
}; |
|||
@ -1,7 +0,0 @@ |
|||
export const fetcher = async <JSON>( |
|||
input: RequestInfo, |
|||
init?: RequestInit |
|||
): Promise<JSON> => { |
|||
const res = await fetch(input, init); |
|||
return res.json() as Promise<JSON>; |
|||
}; |
|||
@ -1,137 +1,63 @@ |
|||
import type React from "react"; |
|||
import { useEffect, useMemo, useRef } from "react"; |
|||
|
|||
import { Pane } from "evergreen-ui"; |
|||
import { majorScale, Pane } from "evergreen-ui"; |
|||
import { Marker } from "maplibre-gl"; |
|||
|
|||
import { useDevice } from "@app/core/stores/deviceStore.js"; |
|||
import Point from "@arcgis/core/geometry/Point"; |
|||
import Graphic from "@arcgis/core/Graphic"; |
|||
import FeatureLayer from "@arcgis/core/layers/FeatureLayer"; |
|||
import LabelClass from "@arcgis/core/layers/support/LabelClass"; |
|||
import Map from "@arcgis/core/Map"; |
|||
import LineCallout3D from "@arcgis/core/symbols/callouts/LineCallout3D"; |
|||
import LabelSymbol3D from "@arcgis/core/symbols/LabelSymbol3D"; |
|||
import TextSymbol3DLayer from "@arcgis/core/symbols/TextSymbol3DLayer"; |
|||
import SceneView from "@arcgis/core/views/SceneView"; |
|||
import { useCreateMapbox } from "@app/core/providers/useCreateMapbox.js"; |
|||
import { useDevice } from "@core/providers/useDevice.js"; |
|||
|
|||
export const MapPage = (): JSX.Element => { |
|||
const { nodes } = useDevice(); |
|||
|
|||
const nodesWithPosition = nodes.filter((node) => node.data.position); |
|||
const ref = useRef<HTMLDivElement>(null); |
|||
|
|||
useEffect(() => { |
|||
console.log(nodesWithPosition); |
|||
}, [nodesWithPosition]); |
|||
const nodeMarkers = useMemo(() => new Map<number, Marker>(), []); |
|||
|
|||
const labelClass = useMemo( |
|||
() => |
|||
new LabelClass({ |
|||
labelExpressionInfo: { |
|||
expression: "$feature.name", |
|||
}, |
|||
symbol: new LabelSymbol3D({ |
|||
symbolLayers: [ |
|||
new TextSymbol3DLayer({ |
|||
text: "{name}", |
|||
material: { |
|||
color: "black", |
|||
}, |
|||
halo: { |
|||
color: [255, 255, 255, 0.7], |
|||
size: 2, |
|||
}, |
|||
font: { |
|||
size: 12, |
|||
weight: "bold", |
|||
}, |
|||
size: 10, |
|||
}), |
|||
], |
|||
verticalOffset: { |
|||
screenLength: 150, |
|||
maxWorldLength: 2000, |
|||
minWorldLength: 30, |
|||
}, |
|||
callout: new LineCallout3D({ |
|||
size: 0.5, |
|||
color: [0, 0, 0], |
|||
border: { |
|||
color: [255, 255, 255], |
|||
}, |
|||
}), |
|||
}), |
|||
}), |
|||
[] |
|||
); |
|||
const ref = useRef<HTMLDivElement>(null); |
|||
|
|||
const points: Graphic[] = nodesWithPosition.map( |
|||
(node, index) => |
|||
node.data.position |
|||
? new Graphic({ |
|||
geometry: new Point({ |
|||
latitude: node.data.position.latitudeI / 1e7, |
|||
longitude: node.data.position.longitudeI / 1e7, |
|||
}), |
|||
attributes: { |
|||
ObjectID: index, |
|||
name: node.data.user?.longName, |
|||
}, |
|||
}) |
|||
: new Graphic() //should be undefined/removed from array
|
|||
); |
|||
const map = useCreateMapbox({ |
|||
ref, |
|||
style: |
|||
"https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json", |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
if (ref.current) { |
|||
const layer = new FeatureLayer({ |
|||
labelsVisible: true, |
|||
labelingInfo: [labelClass], |
|||
source: points, |
|||
fields: [ |
|||
{ |
|||
name: "ObjectID", |
|||
alias: "ObjectID", |
|||
type: "oid", |
|||
}, |
|||
{ |
|||
name: "name", |
|||
alias: "Name", |
|||
type: "string", |
|||
}, |
|||
], |
|||
}); |
|||
|
|||
const map = new Map({ |
|||
basemap: "satellite", |
|||
ground: "world-elevation", |
|||
layers: [layer], |
|||
}); |
|||
|
|||
const scene = new SceneView({ |
|||
container: ref.current, |
|||
map: map, |
|||
camera: { |
|||
position: nodesWithPosition[0] |
|||
? { |
|||
x: nodesWithPosition[0].data.position?.longitudeI ?? 0 / 1e7, |
|||
y: nodesWithPosition[0].data.position?.latitudeI ?? 0 / 1e7, |
|||
z: nodesWithPosition[0].data.position?.altitude ?? 0 / 1e7, |
|||
} |
|||
: { |
|||
y: -35.59, //Longitude
|
|||
x: 148, //Latitude
|
|||
z: 200, //Meters
|
|||
}, |
|||
tilt: 75, |
|||
}, |
|||
}); |
|||
scene.on("click", (event) => { |
|||
void scene.hitTest(event).then((point) => { |
|||
console.log(point); |
|||
}); |
|||
}); |
|||
} |
|||
}, [labelClass, points]); |
|||
|
|||
return <Pane width="100%" height="100%" ref={ref} />; |
|||
nodes.map((n) => { |
|||
if (n.data.position?.longitudeI && n.data.position?.latitudeI && map) { |
|||
if (nodeMarkers.has(n.data.num)) { |
|||
nodeMarkers |
|||
.get(n.data.num) |
|||
?.setLngLat([ |
|||
n.data.position?.longitudeI / 1e7, |
|||
n.data.position?.latitudeI / 1e7, |
|||
]); |
|||
} else { |
|||
nodeMarkers.set( |
|||
n.data.num, |
|||
new Marker() |
|||
.setLngLat([ |
|||
n.data.position?.longitudeI / 1e7, |
|||
n.data.position?.latitudeI / 1e7, |
|||
]) |
|||
.addTo(map) |
|||
); |
|||
} |
|||
} |
|||
}); |
|||
}, [map, nodeMarkers, nodes]); |
|||
|
|||
return ( |
|||
<Pane |
|||
margin={majorScale(3)} |
|||
borderRadius={majorScale(1)} |
|||
background="white" |
|||
elevation={1} |
|||
display="flex" |
|||
flexGrow={1} |
|||
flexDirection="column" |
|||
gap={majorScale(2)} |
|||
overflow="hidden" |
|||
> |
|||
<Pane width="100%" height="100%" ref={ref} /> |
|||
</Pane> |
|||
); |
|||
}; |
|||
|
|||
@ -0,0 +1,14 @@ |
|||
import { IsBoolean, IsEnum, IsInt } from "class-validator"; |
|||
|
|||
import { Protobuf } from "@meshtastic/meshtasticjs"; |
|||
|
|||
export class BluetoothValidation implements Protobuf.Config_BluetoothConfig { |
|||
@IsBoolean() |
|||
enabled: boolean; |
|||
|
|||
@IsEnum(Protobuf.Config_BluetoothConfig_PairingMode) |
|||
mode: Protobuf.Config_BluetoothConfig_PairingMode; |
|||
|
|||
@IsInt() |
|||
fixedPin: number; |
|||
} |
|||
Loading…
Reference in new issue