frontend/src/maps/MapBase.tsx
2025-10-21 10:44:46 -06:00

704 lines
No EOL
22 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import "mapbox-gl/dist/mapbox-gl.css";
import {
Box,
Button,
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
Icon,
IconButton,
LinearProgress,
MenuItem,
Select,
Theme
} from "@mui/material";
import { HomeMarker } from "models/HomeMarker";
import { useGlobalState, useHomeMarkerAPI } from "providers";
import { useMobile, useSnackbar } from "hooks";
import { useCallback } from "react";
import HomeIcon from "products/AgIcons/HomeIcon";
//import { Theme } from "@material-ui/core/styles/createMuiTheme";
import { GpsFixed, Layers } from "@mui/icons-material";
//import Geocoder from "react-map-gl-geocoder";
import "maps/mapboxStyleOverrides.css";
import { pond } from "protobuf-ts/pond";
import MarkerMove from "products/AgIcons/MarkerMove";
import ResponsiveDialog from "common/ResponsiveDialog";
import Markers, { MarkerData } from "./mapMarkers/Markers";
import GeoMapLayer from "./mapLayers/geoMapLayer";
import Geocoder from "./mapControllers/Geocoder";
import { GeometryMapping, shapeFromCoords } from "models/GeometryMapping";
import { FeatureCollection, Feature } from "geojson";
import DrawController from "./mapControllers/drawController";
//import { Geometry } from "geojson";
import Map, { MapRef, Marker, MarkerDragEvent } from "react-map-gl/mapbox";
import { getDistanceUnit } from "utils";
import { MapMouseEvent } from "mapbox-gl";
import { makeStyles } from "@mui/styles";
import { Result } from "@mapbox/mapbox-gl-geocoder";
//const MAPBOX_TOKEN = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN;
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN;
const mapStyleOptions = [
{
label: "Dark",
value: "mapbox://styles/mapbox/dark-v10"
},
{
label: "Satellite with streets",
value: "mapbox://styles/mapbox/satellite-streets-v11"
}
];
const useStyles = makeStyles((theme: Theme) => ({
iconButtons: {
background: theme.palette.background.default,
margin: 5,
boxShadow: "4px 4px 10px black"
},
pin: {
borderRadius: "50rem",
display: "inline-block",
borderBottomRightRadius: "0",
transform: "rotate(45deg)",
cursor: "pointer",
boxShadow: "4px 4px 10px black"
},
geoDot: {
height: "25px",
width: "25px",
backgroundColor: "yellow",
borderRadius: "50%",
border: "5px solid black"
},
geoIconColor: {
color: "black"
},
geoIconSearch: {
animation: "$rotation 3s infinite"
},
"@keyframes rotation": {
from: {
transform: "rotate(0deg)"
},
to: {
transform: "rotate(360deg)"
}
}
}));
export interface ViewData {
longitude: number;
latitude: number;
zoom: number;
transitionDuration?: number;
xOffset?: number;
yOffset?: number;
}
export interface MeasurementData {
key: string;
geoShape: string;
coordinates: number[][];
segmentDistanceKm: number[];
totalDistanceKm: number;
}
interface Props {
mapTools: JSX.Element;
mapClick(mapClickEvent: MapMouseEvent): void;
currentView: ViewData;
ignoreHomeLoad?: boolean;
defaultMapStyle?: string;
layerOptions?: JSX.Element;
markerData?: MarkerData[];
displayMarkerTitles?: boolean;
displayMarkerDetails?: boolean;
customCursor?: JSX.Element;
objectGeoData?: Map<string, pond.GeoData>;
measurementData?: Map<string, MeasurementData>;
objectTitles?: boolean;
editorMode?: "drawPolygon" | "drawLine" | "drawPoint" | "edit" | "none" | "delete";
placingMarker?: boolean;
linePointLimit?: number;
customSearchEntries?: Result[];
drawMeasurement?: boolean;
geocoderResultFunction?: (result: Result) => void;
geocoderTransitionFunction?: (result: Result) => void;
cutHoleInPolygon?: (object: string, coordinates: any[], geoType: string) => void;
editGeoCallback?: (object: string, borders: pond.Shape[], holes?: pond.Shape[]) => void;
addNewShape?: (coordinates: any[], geoType: string) => void;
addNewMeasurement?: (coordinates: number[][], geoType: string) => void;
}
export default function MapBase(props: Props) {
const [startingView, setStartingView] = useState<ViewData | undefined>();
//const [viewport, setViewport] = useState<ViewData>(props.currentView);
const transDuration = 3000; //in milliseconds
const homeMarkerAPI = useHomeMarkerAPI();
const classes = useStyles();
const isMobile = useMobile();
const [mapCursor, setMapCursor] = useState("auto");
const [hcHovered, setHCHovered] = useState(false);
const [gcHovered, setGCHovered] = useState(false);
const [dcHovered, setDCHovered] = useState(false);
const mapRef = useRef<MapRef>(null);
const [haveHome, setHaveHome] = useState(false);
const [homeKey, setHomeKey] = useState("");
const [homePin, setHomePin] = useState<{ longitude: number; latitude: number }>();
const [{ user, as }] = useGlobalState();
const { openSnack } = useSnackbar();
const [stateWatch, setStateWatch] = useState(0);
const [watching, setWatching] = useState(false);
const [locationFound, setLocationFound] = useState(true);
const [geoPosition, setGeoPosition] = useState({ longitude: 0, latitude: 0 });
const [dragAllowed, setDragAllowed] = useState(false);
const [mapStyle, setMapStyle] = useState(
props.defaultMapStyle ?? "mapbox://styles/mapbox/satellite-streets-v11"
);
const [styles, setStyles] = useState(false);
const [msHovered, setMSHovered] = useState(false);
const [layerIDs, setLayerIDs] = useState<string[]>([]);
const [geoCollection, setGeoCollection] = useState<FeatureCollection>();
const [measurementCollection, setMeasurementCollection] = useState<FeatureCollection>();
const [groupSelect, setGroupSelect] = useState(false);
const [currentZoom, setCurrentZoom] = useState(props.currentView.zoom);
const movePos = () => {
navigator.geolocation.getCurrentPosition(
pos => {
setGeoPosition({ longitude: pos.coords.longitude, latitude: pos.coords.latitude });
setLocationFound(true);
if (mapRef.current) {
if (!mapRef.current.isMoving()) {
mapRef.current.flyTo({
center: [pos.coords.longitude, pos.coords.latitude],
duration: transDuration,
zoom: 18
});
}
}
},
() => {
//error callback function
},
{ enableHighAccuracy: true }
);
};
const geolocateToggle = () => {
if (!watching) {
setWatching(true);
setStateWatch(navigator.geolocation.watchPosition(e => movePos()));
setLocationFound(false);
} else {
navigator.geolocation.clearWatch(stateWatch);
setWatching(false);
}
};
//used to change the viewport from the parent
useEffect(() => {
//setViewport(props.currentView);
if (mapRef.current) {
let xOffset = props.currentView.xOffset ?? 0;
let yOffset = props.currentView.yOffset ?? 0;
mapRef.current.flyTo({
offset: [xOffset, yOffset], //this is in pixels
center: [props.currentView.longitude, props.currentView.latitude], //this is in degrees
duration: props.currentView.transitionDuration,
zoom: props.currentView.zoom
});
}
}, [props.currentView]);
const homeControl = () => {
if (!haveHome) {
setHaveHome(true);
if (mapRef.current) {
setHomePin({
longitude: mapRef.current.getCenter().lng,
latitude: mapRef.current.getCenter().lat
});
}
// save the pin to the database
saveHomeMarker();
} else {
if (mapRef.current && homePin) {
mapRef.current.flyTo({
center: [homePin.longitude, homePin.latitude],
duration: transDuration
});
}
}
};
const loadHomeMarkers = useCallback(() => {
homeMarkerAPI
.listHomeMarkers(1, 0, undefined, undefined, undefined, undefined, as)
.then(resp => {
if (!resp.data.homeMarker || resp.data.homeMarker.length < 1) {
setHomePin(undefined);
setHaveHome(false);
setStartingView(props.currentView);
return;
}
let hm = HomeMarker.any(resp.data.homeMarker[0]);
setHomeKey(hm.key());
setHomePin({
longitude: hm.long(),
latitude: hm.lat()
});
setHaveHome(true);
if (props.ignoreHomeLoad) {
setStartingView(props.currentView);
} else if (!startingView) {
setStartingView({
longitude: hm.long(),
latitude: hm.lat(),
zoom: user.settings.mapZoom
});
} else if (mapRef.current) {
mapRef.current.flyTo({
center: [hm.long(), hm.lat()],
zoom: user.settings.mapZoom,
duration: 0
});
}
})
.catch(err => {
console.log(err)
openSnack("Could not load Home Pin");
});
}, [as, homeMarkerAPI, openSnack, props.ignoreHomeLoad, user]); // eslint-disable-line react-hooks/exhaustive-deps
const saveHomeMarker = () => {
if (mapRef.current) {
let hm = HomeMarker.create();
hm.settings.userId = as ? as : user.id();
hm.settings.longitude = mapRef.current.getCenter().lng;
hm.settings.latitude = mapRef.current.getCenter().lat;
homeMarkerAPI
.addHomeMarker(hm.settings, as)
.then(resp => {
setHomeKey(resp.data.homeMarker);
openSnack("New Home Marker Created");
})
.catch(err => openSnack("Failed to save new Home Marker"));
}
};
const updateHomeMarker = (longitude: number, latitude: number) => {
let hm = HomeMarker.create();
if (homePin) {
hm.settings.latitude = latitude;
hm.settings.longitude = longitude;
hm.settings.userId = as ? as : user.id();
homeMarkerAPI
.updateHomeMarker(homeKey, hm.settings, undefined, as)
.then(resp => {
openSnack("Home Marker Moved");
})
.catch(err => openSnack("Failed to Update Home Marker"));
}
};
useEffect(() => {
loadHomeMarkers();
}, [loadHomeMarkers, as]);
const homeDragEnd = (event: MarkerDragEvent) => {
let longitude = event.lngLat.lng;
let latitude = event.lngLat.lat;
setHomePin({ longitude, latitude });
// update the pin in the database
updateHomeMarker(longitude, latitude);
};
const tools = () => {
return (
<Box>
<Box
style={{
marginLeft: 10,
position: "absolute",
top: isMobile ? "120px" : "7%",
left: isMobile ? 0 : 75
}}
className="mapboxgl-ctrl-top-left mapboxgl-ctrl">
{props.mapTools}
</Box>
<Box
style={{
marginRight: 10,
position: "absolute",
bottom: isMobile ? "7%" : "3%",
right: 10
}}
className="mapboxgl-ctrl-bottom-right mapboxgl-ctrl">
<Box>
{dragAllowed && (
<IconButton
className={classes.iconButtons}
style={
groupSelect ? { background: "yellow" } : dcHovered ? { background: "grey" } : {}
}
title="Group Select"
onClick={() => setGroupSelect(!groupSelect)}
onMouseOver={() => setDCHovered(true)}
onMouseOut={() => setDCHovered(false)}>
<MarkerMove />
</IconButton>
)}
</Box>
<Grid container direction={isMobile ? "column" : "row"}>
<Grid>
<IconButton
className={classes.iconButtons}
style={
dragAllowed ? { background: "yellow" } : dcHovered ? { background: "grey" } : {}
}
title="Toggle Drag"
onClick={() => {
setGroupSelect(false);
setDragAllowed(!dragAllowed);
}}
onMouseOver={() => setDCHovered(true)}
onMouseOut={() => setDCHovered(false)}>
<MarkerMove />
</IconButton>
</Grid>
<Grid>
<IconButton
className={classes.iconButtons}
style={msHovered ? { background: "grey" } : {}}
title="Map Style"
onClick={() => setStyles(true)}
onMouseOver={() => setMSHovered(true)}
onMouseOut={() => setMSHovered(false)}>
<Layers />
</IconButton>
</Grid>
<Grid>
<IconButton
className={classes.iconButtons}
style={hcHovered ? { background: "grey" } : {}}
title="Home Pin"
onClick={homeControl}
onMouseOver={() => setHCHovered(true)}
onMouseOut={() => setHCHovered(false)}>
<HomeIcon />
</IconButton>
</Grid>
<Grid>
<IconButton
className={classes.iconButtons}
style={
watching ? { background: "yellow" } : gcHovered ? { background: "grey" } : {}
}
title="Find Me"
onClick={geolocateToggle}
onMouseOver={() => setGCHovered(true)}
onMouseOut={() => setGCHovered(false)}>
<Icon className={locationFound ? "" : classes.geoIconSearch}>
<GpsFixed
classes={{
colorSecondary: classes.geoIconColor
}}
color={watching ? "secondary" : "inherit"}
/>
</Icon>
</IconButton>
</Grid>
</Grid>
</Box>
</Box>
);
};
const mapClick = (event: MapMouseEvent) => {
if (!watching) {
props.mapClick(event);
}
};
const mapStyleDialog = () => {
return (
<ResponsiveDialog open={styles} onClose={() => setStyles(false)}>
<DialogTitle>Select Map Style</DialogTitle>
<DialogContent>
<Select
fullWidth
label="Style"
value={mapStyle}
onChange={e => setMapStyle(e.target.value as string)}>
{mapStyleOptions.map(op => (
<MenuItem key={op.value} value={op.value}>
{op.label}
</MenuItem>
))}
</Select>
{/* layer options from the object controller */}
{props.layerOptions}
</DialogContent>
<DialogActions>
<Button onClick={() => setStyles(false)}>Close</Button>
</DialogActions>
</ResponsiveDialog>
);
};
const onMouseEnter = useCallback(() => setMapCursor("pointer"), []);
const onMouseLeave = useCallback(() => setMapCursor("auto"), []);
useEffect(() => {
if (props.editorMode === "drawPolygon" || props.placingMarker) {
setMapCursor("crosshair");
} else {
setMapCursor("auto");
}
}, [props.editorMode, props.placingMarker]);
useEffect(() => {
if (props.objectGeoData) {
let feats: Feature[] = [];
props.objectGeoData.forEach(data => {
let newFeature = GeometryMapping.geoJSON(data.geoShape, data.shapes, data.holes) as Feature;
newFeature.id = data.objectKey;
newFeature.properties = {
title: data.title,
objectKey: data.objectKey,
fill: data.colour,
lineWidth: data.geoShape === "LineString" ? 5 : 2,
origin: data.origin
};
feats.push(newFeature);
});
let collection: FeatureCollection = {
type: "FeatureCollection",
features: feats
};
setGeoCollection(collection);
}
}, [props.objectGeoData]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
//add the measurementdata into the feature collection to be passed to our geo map layer
if (props.measurementData) {
let feats: Feature[] = [];
props.measurementData.forEach(data => {
let newFeature = GeometryMapping.geoJSON(data.geoShape, [
shapeFromCoords(data.coordinates)
]) as Feature;
newFeature.id = data.key;
newFeature.properties = {
lineWidth: 5,
origin: pond.DataOrigin.DATA_ORIGIN_ADAPTIVE,
totalDist:
getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS
? (data.totalDistanceKm * 1000).toFixed(2)
: (data.totalDistanceKm * 3280.8398950131).toFixed(2)
};
feats.push(newFeature);
});
let collection: FeatureCollection = {
type: "FeatureCollection",
features: feats
};
setMeasurementCollection(collection);
}
}, [props.measurementData]);
const onUpdate = (event: any) => {
let borders: pond.Shape[] = [];
let holes: pond.Shape[] = [];
let objectKey: string = "";
if (event.action === "change_coordinates" && event.features) {
let feature = event.features[0];
if (feature) {
let coords: any[] = feature.geometry.coordinates;
objectKey = feature.id;
if (feature.geometry.type === "Polygon") {
coords.forEach((poly: any[], i) => {
//the first shape is the border
if (i === 0) {
borders.push(shapeFromCoords(poly));
} else {
holes.push(shapeFromCoords(poly));
}
});
} else if (feature.geometry.type === "LineString") {
borders.push(shapeFromCoords(coords));
}
}
}
if (props.editGeoCallback) {
props.editGeoCallback(objectKey, borders, holes.length > 0 ? holes : undefined);
}
};
const onCreate = (event: any, objectToCut?: string, measurement?: boolean) => {
let feature = event.features[0];
//console.log(feature)
//if the string exist we are adding a hole to an existing object
if (objectToCut) {
if (props.cutHoleInPolygon) {
props.cutHoleInPolygon(objectToCut, feature.geometry.coordinates, feature.geometry.type);
}
} else if (measurement) {
if (props.addNewMeasurement) {
props.addNewMeasurement(feature.geometry.coordinates, feature.geometry.type);
}
} else {
if (props.addNewShape) {
//console.log("add new shape")
props.addNewShape(feature.geometry.coordinates, feature.geometry.type);
}
}
};
const getEditableFeatures = () => {
let editableFeatures: FeatureCollection = {
type: "FeatureCollection",
features: []
};
if (geoCollection) {
geoCollection.features.forEach(feature => {
//if the origin of the data comes from us (ie. a field/construction site with the adaptive origin)
if (
feature.properties &&
feature.properties.origin === pond.DataOrigin.DATA_ORIGIN_ADAPTIVE
) {
editableFeatures.features.push(feature);
}
});
}
return editableFeatures;
};
return startingView ? (
<Box height="100%">
{tools()}
{mapStyleDialog()}
<Map
style={{width: "100%", height: "100%"}}
ref={mapRef}
maxZoom={18}
initialViewState={startingView}
// {...viewport}
mapStyle={mapStyle}
onZoomEnd={e => {
setCurrentZoom(e.viewState.zoom);
}}
onMouseEnter={() => {
if (
(props.editorMode === "none" || props.editorMode === undefined) &&
!props.placingMarker &&
!props.drawMeasurement
)
onMouseEnter();
}}
onMouseLeave={() => {
if (
(props.editorMode === "none" || props.editorMode === undefined) &&
!props.placingMarker &&
!props.drawMeasurement
)
onMouseLeave();
}}
interactiveLayerIds={layerIDs}
// onMove={e => {
// setViewport({
// longitude: e.viewState.longitude,
// latitude: e.viewState.latitude,
// zoom: e.viewState.zoom
// });
// }}
mapboxAccessToken={MAPBOX_TOKEN}
cursor={mapCursor}
onClick={e => {
mapClick(e);
}}
doubleClickZoom={false}>
{/* map controllers */}
{MAPBOX_TOKEN && (
<Geocoder
mapboxAccessToken={MAPBOX_TOKEN}
position="top-right"
customEntries={props.customSearchEntries}
resultFunction={props.geocoderResultFunction}
customTransition={props.geocoderTransitionFunction}
/>
)}
<DrawController
featureCollection={getEditableFeatures()}
editMode={props.editorMode}
onCreate={onCreate}
onUpdate={onUpdate}
measurement={props.drawMeasurement}
linePointLimit={props.linePointLimit}
/>
{/* markers */}
{props.markerData && (
<Markers
markerData={props.markerData}
dragOn={dragAllowed}
groupSelect={groupSelect}
mapZoomLevel={currentZoom}
displayDetails={props.displayMarkerDetails}
showMarkerTitle={props.displayMarkerTitles}
/>
)}
{watching && locationFound && (
<Marker longitude={geoPosition.longitude} latitude={geoPosition.latitude}>
<Box className={classes.geoDot} />
</Marker>
)}
{haveHome && homePin && (
<Marker
longitude={homePin.longitude}
latitude={homePin.latitude}
draggable
onDragEnd={homeDragEnd}
offset={[0, -30]}>
<Box
className={classes.pin}
style={{ pointerEvents: "none", width: 50, height: 50, background: "red" }}>
<Box
style={{
transform: "rotate(-45deg)",
width: 30,
height: 30,
marginTop: 10,
marginLeft: 7,
pointerEvents: "none"
}}>
<HomeIcon type="light" height={30} width={30} />
</Box>
</Box>
</Marker>
)}
{/* map layers */}
{geoCollection && (
<GeoMapLayer
objectCollection={geoCollection}
measurementCollection={measurementCollection}
showTitle={props.objectTitles}
setInteractiveLayers={e => {
setLayerIDs(e);
}}
/>
)}
</Map>
</Box>
) : (
<Box>
<LinearProgress />
</Box>
);
}