feat: modify google map state management + add farm creation

This commit is contained in:
Sosokker 2025-03-31 11:24:29 +07:00
parent a5bdac4f2d
commit 89364fae0c
14 changed files with 774 additions and 160 deletions

View File

@ -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
View 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);
}

View File

@ -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
View 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);
}

View File

@ -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,6 +136,8 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
)}
/>
{/* Coordinate Fields (Latitude & Longitude) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
control={form.control}
name="latitude"
@ -94,13 +145,19 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input placeholder="Latitude" {...field} disabled />
<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"
@ -108,13 +165,22 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input placeholder="Longitude" {...field} disabled />
<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., &quot;15 rai&quot;, &quot;10 hectares&quot;).
</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>
);
}

View File

@ -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>

View File

@ -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,6 +21,7 @@ export default function AppLayout({
const form = useForm();
return (
<APIProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!}>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@ -37,5 +39,6 @@ export default function AppLayout({
</FormProvider>
</SidebarInset>
</SidebarProvider>
</APIProvider>
);
}

View File

@ -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,
},
}}
<>
{/* 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>
)}
</GoogleMap>
</LoadScript>
{/* The drawing controls (marker, polygon etc.) are added by useDrawingManager */}
</>
);
};

View 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>
);
};

View 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]);
}

View 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;
}

View File

@ -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",

View File

@ -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

View File

@ -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;
}