mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
feat: modify google map state management + add farm creation
This commit is contained in:
parent
a5bdac4f2d
commit
89364fae0c
@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
@ -58,11 +59,11 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) {
|
||||
type CreateFarmInput struct {
|
||||
Header string `header:"Authorization" required:"true" example:"Bearer token"`
|
||||
Body struct {
|
||||
Name string `json:"name"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
FarmType string `json:"farm_type,omitempty"`
|
||||
TotalSize string `json:"total_size,omitempty"`
|
||||
Name string `json:"Name"`
|
||||
Lat float64 `json:"Lat"`
|
||||
Lon float64 `json:"Lon"`
|
||||
FarmType string `json:"FarmType,omitempty"`
|
||||
TotalSize string `json:"TotalSize,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,7 +136,7 @@ func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*C
|
||||
TotalSize: input.Body.TotalSize,
|
||||
OwnerID: userID,
|
||||
}
|
||||
|
||||
fmt.Println(farm)
|
||||
if err := a.farmRepo.CreateOrUpdate(ctx, farm); err != nil {
|
||||
return nil, huma.Error500InternalServerError("failed to create farm", err)
|
||||
}
|
||||
|
||||
33
frontend/api/crop.ts
Normal file
33
frontend/api/crop.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import axiosInstance from "./config";
|
||||
import type { Cropland } from "@/types";
|
||||
|
||||
export interface CropResponse {
|
||||
croplands: Cropland[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific Crop by FarmID.
|
||||
* Calls GET /crop/farm/{farm_id} and returns fallback data on failure.
|
||||
*/
|
||||
export async function getCrop(farmId: string): Promise<CropResponse> {
|
||||
return axiosInstance.get<CropResponse>(`/crop/farm/${farmId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
// body
|
||||
// {
|
||||
// "farm_id": "string",
|
||||
// "growth_stage": "string",
|
||||
// "land_size": 0,
|
||||
// "name": "string",
|
||||
// "plant_id": "string",
|
||||
// "priority": 0,
|
||||
// "status": "string",
|
||||
// }
|
||||
|
||||
/**
|
||||
* Create a new crop by FarmID.
|
||||
* Calls POST /crop and returns fallback data on failure.
|
||||
*/
|
||||
export async function createCrop(data: Partial<Cropland>): Promise<Cropland> {
|
||||
return axiosInstance.post<Cropland>(`/crop`, data).then((res) => res.data);
|
||||
}
|
||||
@ -22,27 +22,7 @@ export async function createFarm(data: Partial<Farm>): Promise<Farm> {
|
||||
* Calls GET /farms/{farm_id} and returns fallback data on failure.
|
||||
*/
|
||||
export async function getFarm(farmId: string): Promise<Farm> {
|
||||
// Simulate a network delay.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get<Farm>(`/farms/${farmId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching farm ${farmId}. Returning fallback data:`, error);
|
||||
const dummyDate = new Date().toISOString();
|
||||
return {
|
||||
CreatedAt: dummyDate,
|
||||
FarmType: "conventional",
|
||||
Lat: 15.87,
|
||||
Lon: 100.9925,
|
||||
Name: "Fallback Farm",
|
||||
OwnerID: "fallback_owner",
|
||||
TotalSize: "40 hectares",
|
||||
UUID: farmId,
|
||||
UpdatedAt: dummyDate,
|
||||
};
|
||||
}
|
||||
return axiosInstance.get<Farm>(`/farms/${farmId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
10
frontend/api/plant.ts
Normal file
10
frontend/api/plant.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import axiosInstance from "./config";
|
||||
import type { Plant } from "@/types";
|
||||
|
||||
export interface PlantResponse {
|
||||
plants: Plant[];
|
||||
}
|
||||
|
||||
export function getPlants(): Promise<PlantResponse> {
|
||||
return axiosInstance.get<PlantResponse>("/plant").then((res) => res.data);
|
||||
}
|
||||
@ -7,38 +7,66 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Farm } from "@/types";
|
||||
import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
|
||||
import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing";
|
||||
|
||||
// ===================================================================
|
||||
// Schema Definition: Validates form inputs using Zod
|
||||
// See: https://zod.dev
|
||||
// ===================================================================
|
||||
const farmFormSchema = z.object({
|
||||
name: z.string().min(2, "Farm name must be at least 2 characters"),
|
||||
latitude: z.number().min(-90, "Invalid latitude").max(90, "Invalid latitude"),
|
||||
longitude: z.number().min(-180, "Invalid longitude").max(180, "Invalid longitude"),
|
||||
latitude: z
|
||||
.number({ invalid_type_error: "Latitude must be a number" })
|
||||
.min(-90, "Invalid latitude")
|
||||
.max(90, "Invalid latitude")
|
||||
.refine((val) => val !== 0, { message: "Please select a location on the map." }),
|
||||
longitude: z
|
||||
.number({ invalid_type_error: "Longitude must be a number" })
|
||||
.min(-180, "Invalid longitude")
|
||||
.max(180, "Invalid longitude")
|
||||
.refine((val) => val !== 0, { message: "Please select a location on the map." }),
|
||||
type: z.string().min(1, "Please select a farm type"),
|
||||
area: z.string().optional(),
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Component Props Declaration
|
||||
// ===================================================================
|
||||
export interface AddFarmFormProps {
|
||||
onSubmit: (data: Partial<Farm>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Component: AddFarmForm
|
||||
// - Manages the creation of new farm records.
|
||||
// - Uses React Hook Form with Zod for form validation.
|
||||
// - Includes a map component for coordinate selection.
|
||||
// ===================================================================
|
||||
export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
// ---------------------------------------------------------------
|
||||
// State and Form Setup
|
||||
// ---------------------------------------------------------------
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof farmFormSchema>>({
|
||||
resolver: zodResolver(farmFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
latitude: 0,
|
||||
latitude: 0, // Defaults handled by validation (marker must be selected)
|
||||
longitude: 0,
|
||||
type: "",
|
||||
area: "",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Form Submission Handler
|
||||
// - Converts form data to the expected Farm shape.
|
||||
// ---------------------------------------------------------------
|
||||
const handleSubmit = async (values: z.infer<typeof farmFormSchema>) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
@ -58,20 +86,41 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAreaSelected = (coordinates: { lat: number; lng: number }[]) => {
|
||||
if (coordinates.length > 0) {
|
||||
const { lat, lng } = coordinates[0];
|
||||
form.setValue("latitude", lat);
|
||||
form.setValue("longitude", lng);
|
||||
}
|
||||
};
|
||||
// ---------------------------------------------------------------
|
||||
// Map-to-Form Coordination: Update coordinates from the map
|
||||
// - Uses useCallback to preserve reference and optimize re-renders.
|
||||
// ---------------------------------------------------------------
|
||||
const handleShapeDrawn = useCallback(
|
||||
(data: ShapeData) => {
|
||||
// Log incoming shape data for debugging
|
||||
console.log("Shape drawn in form:", data);
|
||||
|
||||
// Only update the form if a single marker (i.e. point) is used
|
||||
if (data.type === "marker") {
|
||||
const { lat, lng } = data.position;
|
||||
form.setValue("latitude", lat, { shouldValidate: true });
|
||||
form.setValue("longitude", lng, { shouldValidate: true });
|
||||
console.log(`Set form lat: ${lat}, lng: ${lng}`);
|
||||
} else {
|
||||
// Note: Only markers update coordinates. Other shapes could be handled later.
|
||||
console.log(`Received shape type '${data.type}', but only 'marker' updates the form coordinates.`);
|
||||
}
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
// ===================================================================
|
||||
// Render: Split into two main sections - Form and Map
|
||||
// ===================================================================
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Form Section */}
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col lg:flex-row gap-6 p-4">
|
||||
{/* ==============================
|
||||
Start of Form Section
|
||||
============================== */}
|
||||
<div className="lg:flex-[1]">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{/* Farm Name Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@ -87,34 +136,51 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="latitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Latitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Latitude" {...field} disabled />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="longitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Longitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Longitude" {...field} disabled />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Coordinate Fields (Latitude & Longitude) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="latitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Latitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Select on map"
|
||||
{...field}
|
||||
value={field.value ? field.value.toFixed(6) : ""}
|
||||
disabled
|
||||
readOnly
|
||||
className="disabled:opacity-100 disabled:cursor-default"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="longitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Longitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Select on map"
|
||||
{...field}
|
||||
value={field.value ? field.value.toFixed(6) : ""}
|
||||
disabled
|
||||
readOnly
|
||||
className="disabled:opacity-100 disabled:cursor-default"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Farm Type Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
@ -140,6 +206,7 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Total Area Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
@ -147,15 +214,18 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
<FormItem>
|
||||
<FormLabel>Total Area (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., 10 hectares" {...field} />
|
||||
<Input placeholder="e.g., 10 hectares" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormDescription>The total size of your farm</FormDescription>
|
||||
<FormDescription>
|
||||
The total size of your farm (e.g., "15 rai", "10 hectares").
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{/* Submit and Cancel Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -173,12 +243,27 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
{/* ==============================
|
||||
End of Form Section
|
||||
============================== */}
|
||||
|
||||
{/* Map Section */}
|
||||
<div className="flex-1">
|
||||
<FormLabel>Farm Location</FormLabel>
|
||||
<GoogleMapWithDrawing onAreaSelected={handleAreaSelected} />
|
||||
{/* ==============================
|
||||
Start of Map Section
|
||||
- Renders an interactive map for coordinate selection.
|
||||
============================== */}
|
||||
<div className="lg:flex-[2] min-h-[400px] lg:min-h-0 flex flex-col">
|
||||
<FormLabel>Farm Location (Draw marker on map)</FormLabel>
|
||||
<div className="mt-2 rounded-md overflow-hidden border flex-grow">
|
||||
<GoogleMapWithDrawing onShapeDrawn={handleShapeDrawn} />
|
||||
</div>
|
||||
<FormDescription className="mt-2">
|
||||
Select the marker tool above the map and click a location to set the latitude and longitude for your farm.
|
||||
Only markers will update the coordinates.
|
||||
</FormDescription>
|
||||
</div>
|
||||
{/* ==============================
|
||||
End of Map Section
|
||||
============================== */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
|
||||
</div>
|
||||
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">Crops</p>
|
||||
<p className="font-medium">{farm.crops}</p>
|
||||
<p className="font-medium">{farm.Crops ? farm.Crops.length : 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,7 @@ import { extractRoute } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { APIProvider } from "@vis.gl/react-google-maps";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
@ -20,22 +21,24 @@ export default function AppLayout({
|
||||
const form = useForm();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<FormProvider {...form}>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<DynamicBreadcrumb pathname={currentPathname} />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
<Toaster />
|
||||
</FormProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<APIProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!}>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<FormProvider {...form}>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<DynamicBreadcrumb pathname={currentPathname} />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
<Toaster />
|
||||
</FormProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</APIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,78 +1,51 @@
|
||||
import { GoogleMap, LoadScript, DrawingManager } from "@react-google-maps/api";
|
||||
import { useState, useCallback } from "react";
|
||||
// google-map-with-drawing.tsx
|
||||
import React from "react";
|
||||
import { ControlPosition, Map, MapControl } from "@vis.gl/react-google-maps";
|
||||
|
||||
const containerStyle = {
|
||||
width: "100%",
|
||||
height: "500px",
|
||||
};
|
||||
import { UndoRedoControl } from "@/components/map-component/undo-redo-control";
|
||||
// Import ShapeData and useDrawingManager from the correct path
|
||||
import { useDrawingManager, type ShapeData } from "@/components/map-component/use-drawing-manager"; // Adjust path if needed
|
||||
|
||||
const center = { lat: 13.7563, lng: 100.5018 }; // Example: Bangkok, Thailand
|
||||
// Export the type so the form can use it
|
||||
export { type ShapeData };
|
||||
|
||||
// Define props for the component
|
||||
interface GoogleMapWithDrawingProps {
|
||||
onAreaSelected: (data: { lat: number; lng: number }[]) => void;
|
||||
onShapeDrawn: (data: ShapeData) => void; // Callback prop
|
||||
// Add any other props you might need, e.g., initialCenter, initialZoom
|
||||
initialCenter?: { lat: number; lng: number };
|
||||
initialZoom?: number;
|
||||
}
|
||||
|
||||
// Rename DrawingExample to GoogleMapWithDrawing and accept props
|
||||
const GoogleMapWithDrawing = ({
|
||||
onAreaSelected,
|
||||
onShapeDrawn, // Destructure the callback prop
|
||||
initialCenter = { lat: 13.7563, lng: 100.5018 }, // Default center
|
||||
initialZoom = 10, // Default zoom
|
||||
}: GoogleMapWithDrawingProps) => {
|
||||
const [map, setMap] = useState<google.maps.Map | null>(null);
|
||||
|
||||
const onDrawingComplete = useCallback(
|
||||
(overlay: google.maps.drawing.OverlayCompleteEvent) => {
|
||||
const shape = overlay.overlay;
|
||||
|
||||
if (shape instanceof google.maps.Polyline) {
|
||||
const path = shape.getPath();
|
||||
const coordinates = path.getArray().map((latLng) => ({
|
||||
lat: latLng.lat(),
|
||||
lng: latLng.lng(),
|
||||
}));
|
||||
// console.log("Polyline coordinates:", coordinates);
|
||||
onAreaSelected(coordinates);
|
||||
} else {
|
||||
console.log("Unknown shape detected:", shape);
|
||||
}
|
||||
},
|
||||
[onAreaSelected]
|
||||
);
|
||||
// Pass the onShapeDrawn callback directly to the hook
|
||||
const drawingManager = useDrawingManager(onShapeDrawn);
|
||||
|
||||
return (
|
||||
<LoadScript
|
||||
googleMapsApiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!}
|
||||
libraries={["drawing"]}
|
||||
>
|
||||
<GoogleMap
|
||||
mapContainerStyle={containerStyle}
|
||||
center={center}
|
||||
zoom={10}
|
||||
onLoad={(map) => setMap(map)}
|
||||
>
|
||||
{map && (
|
||||
<DrawingManager
|
||||
onOverlayComplete={onDrawingComplete}
|
||||
options={{
|
||||
drawingControl: true,
|
||||
drawingControlOptions: {
|
||||
position: google.maps.ControlPosition.TOP_CENTER,
|
||||
drawingModes: [
|
||||
google.maps.drawing.OverlayType.POLYLINE,
|
||||
],
|
||||
},
|
||||
polygonOptions: {
|
||||
fillColor: "#FF0000",
|
||||
fillOpacity: 0.5,
|
||||
strokeWeight: 2,
|
||||
},
|
||||
rectangleOptions: {
|
||||
fillColor: "#00FF00",
|
||||
fillOpacity: 0.5,
|
||||
strokeWeight: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GoogleMap>
|
||||
</LoadScript>
|
||||
<>
|
||||
{/* Use props for map defaults */}
|
||||
<Map
|
||||
defaultZoom={initialZoom}
|
||||
defaultCenter={initialCenter}
|
||||
gestureHandling={"greedy"}
|
||||
disableDefaultUI={true}
|
||||
mapId={"YOUR_MAP_ID"} // Recommended: Add a Map ID
|
||||
/>
|
||||
|
||||
{/* Render controls only if drawingManager is available */}
|
||||
{drawingManager && (
|
||||
<MapControl position={ControlPosition.TOP_LEFT}>
|
||||
{/* Pass drawingManager to UndoRedoControl */}
|
||||
<UndoRedoControl drawingManager={drawingManager} />
|
||||
</MapControl>
|
||||
)}
|
||||
{/* The drawing controls (marker, polygon etc.) are added by useDrawingManager */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
45
frontend/components/map-component/undo-redo-control.tsx
Normal file
45
frontend/components/map-component/undo-redo-control.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { useReducer, useRef } from "react";
|
||||
import { useMap } from "@vis.gl/react-google-maps";
|
||||
|
||||
import reducer, { useDrawingManagerEvents, useOverlaySnapshots } from "@/components/map-component/undo-redo";
|
||||
|
||||
import { DrawingActionKind } from "@/types";
|
||||
|
||||
interface Props {
|
||||
drawingManager: google.maps.drawing.DrawingManager | null;
|
||||
}
|
||||
|
||||
export const UndoRedoControl = ({ drawingManager }: Props) => {
|
||||
const map = useMap();
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
past: [],
|
||||
now: [],
|
||||
future: [],
|
||||
});
|
||||
|
||||
// We need this ref to prevent infinite loops in certain cases.
|
||||
// For example when the radius of circle is set via code (and not by user interaction)
|
||||
// the radius_changed event gets triggered again. This would cause an infinite loop.
|
||||
// This solution can be improved by comparing old vs. new values. For now we turn
|
||||
// off the "updating" when snapshot changes are applied back to the overlays.
|
||||
const overlaysShouldUpdateRef = useRef<boolean>(false);
|
||||
|
||||
useDrawingManagerEvents(drawingManager, overlaysShouldUpdateRef, dispatch);
|
||||
useOverlaySnapshots(map, state, overlaysShouldUpdateRef);
|
||||
|
||||
return (
|
||||
<div className="drawing-history">
|
||||
<button onClick={() => dispatch({ type: DrawingActionKind.UNDO })} disabled={!state.past.length}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M280-200v-80h284q63 0 109.5-40T720-420q0-60-46.5-100T564-560H312l104 104-56 56-200-200 200-200 56 56-104 104h252q97 0 166.5 63T800-420q0 94-69.5 157T564-200H280Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => dispatch({ type: DrawingActionKind.REDO })} disabled={!state.future.length}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M396-200q-97 0-166.5-63T160-420q0-94 69.5-157T396-640h252L544-744l56-56 200 200-200 200-56-56 104-104H396q-63 0-109.5 40T240-420q0 60 46.5 100T396-280h284v80H396Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
217
frontend/components/map-component/undo-redo.ts
Normal file
217
frontend/components/map-component/undo-redo.ts
Normal file
@ -0,0 +1,217 @@
|
||||
import { Dispatch, MutableRefObject, useEffect } from "react";
|
||||
|
||||
import {
|
||||
Action,
|
||||
DrawResult,
|
||||
DrawingActionKind,
|
||||
Overlay,
|
||||
Snapshot,
|
||||
State,
|
||||
isCircle,
|
||||
isMarker,
|
||||
isPolygon,
|
||||
isPolyline,
|
||||
isRectangle,
|
||||
} from "@/types";
|
||||
|
||||
export default function reducer(state: State, action: Action) {
|
||||
switch (action.type) {
|
||||
// This action is called whenever anything changes on any overlay.
|
||||
// We then take a snapshot of the relevant values of each overlay and
|
||||
// save them as the new "now". The old "now" is added to the "past" stack
|
||||
case DrawingActionKind.UPDATE_OVERLAYS: {
|
||||
const overlays = state.now.map((overlay: Overlay) => {
|
||||
const snapshot: Snapshot = {};
|
||||
const { geometry } = overlay;
|
||||
|
||||
if (isCircle(geometry)) {
|
||||
snapshot.center = geometry.getCenter()?.toJSON();
|
||||
snapshot.radius = geometry.getRadius();
|
||||
} else if (isMarker(geometry)) {
|
||||
snapshot.position = geometry.getPosition()?.toJSON();
|
||||
} else if (isPolygon(geometry) || isPolyline(geometry)) {
|
||||
snapshot.path = geometry.getPath()?.getArray();
|
||||
} else if (isRectangle(geometry)) {
|
||||
snapshot.bounds = geometry.getBounds()?.toJSON();
|
||||
}
|
||||
|
||||
return {
|
||||
...overlay,
|
||||
snapshot,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
now: [...overlays],
|
||||
past: [...state.past, state.now],
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
// This action is called when a new overlay is added to the map.
|
||||
// We then take a snapshot of the relevant values of the new overlay and
|
||||
// add it to the "now" state. The old "now" is added to the "past" stack
|
||||
case DrawingActionKind.SET_OVERLAY: {
|
||||
const { overlay } = action.payload;
|
||||
|
||||
const snapshot: Snapshot = {};
|
||||
|
||||
if (isCircle(overlay)) {
|
||||
snapshot.center = overlay.getCenter()?.toJSON();
|
||||
snapshot.radius = overlay.getRadius();
|
||||
} else if (isMarker(overlay)) {
|
||||
snapshot.position = overlay.getPosition()?.toJSON();
|
||||
} else if (isPolygon(overlay) || isPolyline(overlay)) {
|
||||
snapshot.path = overlay.getPath()?.getArray();
|
||||
} else if (isRectangle(overlay)) {
|
||||
snapshot.bounds = overlay.getBounds()?.toJSON();
|
||||
}
|
||||
|
||||
return {
|
||||
past: [...state.past, state.now],
|
||||
now: [
|
||||
...state.now,
|
||||
{
|
||||
type: action.payload.type,
|
||||
geometry: action.payload.overlay,
|
||||
snapshot,
|
||||
},
|
||||
],
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
// This action is called when the undo button is clicked.
|
||||
// Get the top item from the "past" stack and set it as the new "now".
|
||||
// Add the old "now" to the "future" stack to enable redo functionality
|
||||
case DrawingActionKind.UNDO: {
|
||||
const last = state.past.slice(-1)[0];
|
||||
|
||||
if (!last) return state;
|
||||
|
||||
return {
|
||||
past: [...state.past].slice(0, -1),
|
||||
now: last,
|
||||
future: state.now ? [...state.future, state.now] : state.future,
|
||||
};
|
||||
}
|
||||
|
||||
// This action is called when the redo button is clicked.
|
||||
// Get the top item from the "future" stack and set it as the new "now".
|
||||
// Add the old "now" to the "past" stack to enable undo functionality
|
||||
case DrawingActionKind.REDO: {
|
||||
const next = state.future.slice(-1)[0];
|
||||
|
||||
if (!next) return state;
|
||||
|
||||
return {
|
||||
past: state.now ? [...state.past, state.now] : state.past,
|
||||
now: next,
|
||||
future: [...state.future].slice(0, -1),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drawing manager events
|
||||
export function useDrawingManagerEvents(
|
||||
drawingManager: google.maps.drawing.DrawingManager | null,
|
||||
overlaysShouldUpdateRef: MutableRefObject<boolean>,
|
||||
dispatch: Dispatch<Action>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!drawingManager) return;
|
||||
|
||||
const eventListeners: Array<google.maps.MapsEventListener> = [];
|
||||
|
||||
const addUpdateListener = (eventName: string, drawResult: DrawResult) => {
|
||||
const updateListener = google.maps.event.addListener(drawResult.overlay, eventName, () => {
|
||||
if (eventName === "dragstart") {
|
||||
overlaysShouldUpdateRef.current = false;
|
||||
}
|
||||
|
||||
if (eventName === "dragend") {
|
||||
overlaysShouldUpdateRef.current = true;
|
||||
}
|
||||
|
||||
if (overlaysShouldUpdateRef.current) {
|
||||
dispatch({ type: DrawingActionKind.UPDATE_OVERLAYS });
|
||||
}
|
||||
});
|
||||
|
||||
eventListeners.push(updateListener);
|
||||
};
|
||||
|
||||
const overlayCompleteListener = google.maps.event.addListener(
|
||||
drawingManager,
|
||||
"overlaycomplete",
|
||||
(drawResult: DrawResult) => {
|
||||
switch (drawResult.type) {
|
||||
case google.maps.drawing.OverlayType.CIRCLE:
|
||||
["center_changed", "radius_changed"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.MARKER:
|
||||
["dragend"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.POLYGON:
|
||||
case google.maps.drawing.OverlayType.POLYLINE:
|
||||
["mouseup"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
|
||||
case google.maps.drawing.OverlayType.RECTANGLE:
|
||||
["bounds_changed", "dragstart", "dragend"].forEach((eventName) => addUpdateListener(eventName, drawResult));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
dispatch({ type: DrawingActionKind.SET_OVERLAY, payload: drawResult });
|
||||
}
|
||||
);
|
||||
|
||||
eventListeners.push(overlayCompleteListener);
|
||||
|
||||
return () => {
|
||||
eventListeners.forEach((listener) => google.maps.event.removeListener(listener));
|
||||
};
|
||||
}, [dispatch, drawingManager, overlaysShouldUpdateRef]);
|
||||
}
|
||||
|
||||
// Update overlays with the current "snapshot" when the "now" state changes
|
||||
export function useOverlaySnapshots(
|
||||
map: google.maps.Map | null,
|
||||
state: State,
|
||||
overlaysShouldUpdateRef: MutableRefObject<boolean>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!map || !state.now) return;
|
||||
|
||||
for (const overlay of state.now) {
|
||||
overlaysShouldUpdateRef.current = false;
|
||||
|
||||
overlay.geometry.setMap(map);
|
||||
|
||||
const { radius, center, position, path, bounds } = overlay.snapshot;
|
||||
|
||||
if (isCircle(overlay.geometry)) {
|
||||
overlay.geometry.setRadius(radius ?? 0);
|
||||
overlay.geometry.setCenter(center ?? null);
|
||||
} else if (isMarker(overlay.geometry)) {
|
||||
overlay.geometry.setPosition(position);
|
||||
} else if (isPolygon(overlay.geometry) || isPolyline(overlay.geometry)) {
|
||||
overlay.geometry.setPath(path ?? []);
|
||||
} else if (isRectangle(overlay.geometry)) {
|
||||
overlay.geometry.setBounds(bounds ?? null);
|
||||
}
|
||||
|
||||
overlaysShouldUpdateRef.current = true;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const overlay of state.now) {
|
||||
overlay.geometry.setMap(null);
|
||||
}
|
||||
};
|
||||
}, [map, overlaysShouldUpdateRef, state.now]);
|
||||
}
|
||||
139
frontend/components/map-component/use-drawing-manager.tsx
Normal file
139
frontend/components/map-component/use-drawing-manager.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
// use-drawing-manager.tsx
|
||||
import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Define types for the data we'll pass back
|
||||
type MarkerData = { type: "marker"; position: { lat: number; lng: number } };
|
||||
type PolygonData = { type: "polygon"; path: { lat: number; lng: number }[] };
|
||||
type PolylineData = { type: "polyline"; path: { lat: number; lng: number }[] };
|
||||
// Add other types (Rectangle, Circle) if you enable them
|
||||
// type RectangleData = { type: 'rectangle'; bounds: { north: number; east: number; south: number; west: number } };
|
||||
// type CircleData = { type: 'circle'; center: { lat: number; lng: number }; radius: number };
|
||||
|
||||
export type ShapeData = MarkerData | PolygonData | PolylineData; // | RectangleData | CircleData;
|
||||
|
||||
// Add the callback function type to the hook's arguments
|
||||
export function useDrawingManager(
|
||||
onOverlayComplete?: (data: ShapeData) => void,
|
||||
initialValue: google.maps.drawing.DrawingManager | null = null
|
||||
) {
|
||||
const map = useMap();
|
||||
const drawing = useMapsLibrary("drawing");
|
||||
|
||||
const [drawingManager, setDrawingManager] = useState<google.maps.drawing.DrawingManager | null>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !drawing) return;
|
||||
|
||||
const newDrawingManager = new drawing.DrawingManager({
|
||||
map,
|
||||
// drawingMode: google.maps.drawing.OverlayType.MARKER, // You might want to set initial mode to null or let user choose
|
||||
drawingMode: null, // Start without an active drawing mode
|
||||
drawingControl: true,
|
||||
drawingControlOptions: {
|
||||
position: google.maps.ControlPosition.TOP_CENTER,
|
||||
drawingModes: [
|
||||
google.maps.drawing.OverlayType.MARKER,
|
||||
// google.maps.drawing.OverlayType.CIRCLE,
|
||||
google.maps.drawing.OverlayType.POLYGON,
|
||||
google.maps.drawing.OverlayType.POLYLINE,
|
||||
// google.maps.drawing.OverlayType.RECTANGLE,
|
||||
],
|
||||
},
|
||||
markerOptions: {
|
||||
draggable: true,
|
||||
},
|
||||
// circleOptions: { // Uncomment if using circles
|
||||
// editable: false,
|
||||
// },
|
||||
polygonOptions: {
|
||||
editable: true,
|
||||
draggable: true,
|
||||
},
|
||||
// rectangleOptions: { // Uncomment if using rectangles
|
||||
// editable: true,
|
||||
// draggable: true,
|
||||
// },
|
||||
polylineOptions: {
|
||||
editable: true,
|
||||
draggable: true,
|
||||
},
|
||||
});
|
||||
|
||||
setDrawingManager(newDrawingManager);
|
||||
|
||||
// --- Add Event Listener ---
|
||||
const overlayCompleteListener = google.maps.event.addListener(
|
||||
newDrawingManager,
|
||||
"overlaycomplete",
|
||||
(event: google.maps.drawing.OverlayCompleteEvent) => {
|
||||
let data: ShapeData | null = null;
|
||||
const overlay = event.overlay;
|
||||
|
||||
// Extract coordinates based on type
|
||||
switch (event.type) {
|
||||
case google.maps.drawing.OverlayType.MARKER:
|
||||
const marker = overlay as google.maps.Marker;
|
||||
const position = marker.getPosition();
|
||||
if (position) {
|
||||
data = {
|
||||
type: "marker",
|
||||
position: { lat: position.lat(), lng: position.lng() },
|
||||
};
|
||||
}
|
||||
// Optional: remove the drawn marker immediately if you only want the data
|
||||
// marker.setMap(null);
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.POLYGON:
|
||||
const polygon = overlay as google.maps.Polygon;
|
||||
const path = polygon.getPath().getArray();
|
||||
data = {
|
||||
type: "polygon",
|
||||
path: path.map((latLng) => ({ lat: latLng.lat(), lng: latLng.lng() })),
|
||||
};
|
||||
// Optional: remove the drawn polygon
|
||||
// polygon.setMap(null);
|
||||
break;
|
||||
|
||||
case google.maps.drawing.OverlayType.POLYLINE:
|
||||
const polyline = overlay as google.maps.Polyline;
|
||||
const linePath = polyline.getPath().getArray();
|
||||
data = {
|
||||
type: "polyline",
|
||||
path: linePath.map((latLng) => ({ lat: latLng.lat(), lng: latLng.lng() })),
|
||||
};
|
||||
// Optional: remove the drawn polyline
|
||||
// polyline.setMap(null);
|
||||
break;
|
||||
|
||||
// Add cases for RECTANGLE and CIRCLE if you enable them
|
||||
|
||||
default:
|
||||
console.warn("Unhandled overlay type:", event.type);
|
||||
break;
|
||||
}
|
||||
|
||||
// Call the callback function if provided and data was extracted
|
||||
if (data && onOverlayComplete) {
|
||||
onOverlayComplete(data);
|
||||
}
|
||||
|
||||
// Optional: Set drawing mode back to null after completion
|
||||
// newDrawingManager.setDrawingMode(null);
|
||||
}
|
||||
);
|
||||
// --- End Event Listener ---
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Remove the event listener
|
||||
google.maps.event.removeListener(overlayCompleteListener);
|
||||
// Remove the drawing manager from the map
|
||||
newDrawingManager.setMap(null);
|
||||
};
|
||||
// Add onOverlayComplete to dependency array to ensure the latest callback is used
|
||||
}, [map, drawing, onOverlayComplete]);
|
||||
|
||||
return drawingManager;
|
||||
}
|
||||
@ -31,6 +31,7 @@
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@vis.gl/react-google-maps": "^1.5.2",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@ -74,6 +74,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.66.0
|
||||
version: 5.67.3(react@19.0.0)
|
||||
'@vis.gl/react-google-maps':
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.8.3
|
||||
@ -1068,6 +1071,12 @@ packages:
|
||||
resolution: {integrity: sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vis.gl/react-google-maps@1.5.2':
|
||||
resolution: {integrity: sha512-0Ypmde7M73GgV4TgcaUTNKXsbcXWToPVuawMNrVg7htXmhpEfLARHwhtmP6N1da3od195ZKC8ShXzC6Vm+zYHQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0 || ^19.0 || ^19.0.0-rc'
|
||||
react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc'
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@ -3612,6 +3621,13 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.26.1
|
||||
eslint-visitor-keys: 4.2.0
|
||||
|
||||
'@vis.gl/react-google-maps@1.5.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@types/google.maps': 3.58.1
|
||||
fast-deep-equal: 3.1.3
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.14.1):
|
||||
dependencies:
|
||||
acorn: 8.14.1
|
||||
|
||||
@ -1,3 +1,28 @@
|
||||
export interface Plant {
|
||||
UUID: string;
|
||||
Name: string;
|
||||
Variety: string;
|
||||
AverageHeight: number;
|
||||
DaysToEmerge: number;
|
||||
DaysToFlower: number;
|
||||
DaysToMaturity: number;
|
||||
EstimateLossRate: number;
|
||||
EstimateRevenuePerHU: number;
|
||||
HarvestUnitID: number;
|
||||
HarvestWindow: number;
|
||||
IsPerennial: boolean;
|
||||
LightProfileID: number;
|
||||
OptimalTemp: number;
|
||||
PHValue: number;
|
||||
PlantingDepth: number;
|
||||
PlantingDetail: string;
|
||||
RowSpacing: number;
|
||||
SoilConditionID: number;
|
||||
WaterNeeds: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
}
|
||||
|
||||
export interface Crop {
|
||||
id: string;
|
||||
farmId: string;
|
||||
@ -11,6 +36,19 @@ export interface Crop {
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface Cropland {
|
||||
UUID: string;
|
||||
Name: string;
|
||||
Status: string;
|
||||
Priority: number;
|
||||
LandSize: number;
|
||||
GrowthStage: string;
|
||||
PlantID: string;
|
||||
FarmID: string;
|
||||
CreatedAt: Date;
|
||||
UpdatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CropAnalytics {
|
||||
cropId: string;
|
||||
growthProgress: number;
|
||||
@ -41,6 +79,7 @@ export interface Farm {
|
||||
TotalSize: string;
|
||||
UUID: string;
|
||||
UpdatedAt: Date;
|
||||
Crops: Cropland[];
|
||||
}
|
||||
|
||||
// export interface Farm {
|
||||
@ -108,3 +147,75 @@ export interface Blog {
|
||||
featured?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ----------- Maps -----------$
|
||||
|
||||
export type OverlayGeometry =
|
||||
| google.maps.Marker
|
||||
| google.maps.Polygon
|
||||
| google.maps.Polyline
|
||||
| google.maps.Rectangle
|
||||
| google.maps.Circle;
|
||||
|
||||
export interface DrawResult {
|
||||
type: google.maps.drawing.OverlayType;
|
||||
overlay: OverlayGeometry;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
radius?: number;
|
||||
center?: google.maps.LatLngLiteral;
|
||||
position?: google.maps.LatLngLiteral;
|
||||
path?: Array<google.maps.LatLng>;
|
||||
bounds?: google.maps.LatLngBoundsLiteral;
|
||||
}
|
||||
|
||||
export interface Overlay {
|
||||
type: google.maps.drawing.OverlayType;
|
||||
geometry: OverlayGeometry;
|
||||
snapshot: Snapshot;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
past: Array<Array<Overlay>>;
|
||||
now: Array<Overlay>;
|
||||
future: Array<Array<Overlay>>;
|
||||
}
|
||||
|
||||
export enum DrawingActionKind {
|
||||
SET_OVERLAY = "SET_OVERLAY",
|
||||
UPDATE_OVERLAYS = "UPDATE_OVERLAYS",
|
||||
UNDO = "UNDO",
|
||||
REDO = "REDO",
|
||||
}
|
||||
|
||||
export interface ActionWithTypeOnly {
|
||||
type: Exclude<DrawingActionKind, DrawingActionKind.SET_OVERLAY>;
|
||||
}
|
||||
|
||||
export interface SetOverlayAction {
|
||||
type: DrawingActionKind.SET_OVERLAY;
|
||||
payload: DrawResult;
|
||||
}
|
||||
|
||||
export type Action = ActionWithTypeOnly | SetOverlayAction;
|
||||
|
||||
export function isCircle(overlay: OverlayGeometry): overlay is google.maps.Circle {
|
||||
return (overlay as google.maps.Circle).getCenter !== undefined;
|
||||
}
|
||||
|
||||
export function isMarker(overlay: OverlayGeometry): overlay is google.maps.Marker {
|
||||
return (overlay as google.maps.Marker).getPosition !== undefined;
|
||||
}
|
||||
|
||||
export function isPolygon(overlay: OverlayGeometry): overlay is google.maps.Polygon {
|
||||
return (overlay as google.maps.Polygon).getPath !== undefined;
|
||||
}
|
||||
|
||||
export function isPolyline(overlay: OverlayGeometry): overlay is google.maps.Polyline {
|
||||
return (overlay as google.maps.Polyline).getPath !== undefined;
|
||||
}
|
||||
|
||||
export function isRectangle(overlay: OverlayGeometry): overlay is google.maps.Rectangle {
|
||||
return (overlay as google.maps.Rectangle).getBounds !== undefined;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user