diff --git a/backend/internal/api/farm.go b/backend/internal/api/farm.go index 38d77e8..647e72d 100644 --- a/backend/internal/api/farm.go +++ b/backend/internal/api/farm.go @@ -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) } diff --git a/frontend/api/crop.ts b/frontend/api/crop.ts new file mode 100644 index 0000000..ea2c41a --- /dev/null +++ b/frontend/api/crop.ts @@ -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 { + return axiosInstance.get(`/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): Promise { + return axiosInstance.post(`/crop`, data).then((res) => res.data); +} diff --git a/frontend/api/farm.ts b/frontend/api/farm.ts index 19e09dd..d56174b 100644 --- a/frontend/api/farm.ts +++ b/frontend/api/farm.ts @@ -22,27 +22,7 @@ export async function createFarm(data: Partial): Promise { * Calls GET /farms/{farm_id} and returns fallback data on failure. */ export async function getFarm(farmId: string): Promise { - // Simulate a network delay. - await new Promise((resolve) => setTimeout(resolve, 600)); - - try { - const response = await axiosInstance.get(`/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(`/farms/${farmId}`).then((res) => res.data); } /** diff --git a/frontend/api/plant.ts b/frontend/api/plant.ts new file mode 100644 index 0000000..8b2d6a1 --- /dev/null +++ b/frontend/api/plant.ts @@ -0,0 +1,10 @@ +import axiosInstance from "./config"; +import type { Plant } from "@/types"; + +export interface PlantResponse { + plants: Plant[]; +} + +export function getPlants(): Promise { + return axiosInstance.get("/plant").then((res) => res.data); +} diff --git a/frontend/app/(sidebar)/farms/add-farm-form.tsx b/frontend/app/(sidebar)/farms/add-farm-form.tsx index 91fcba2..97fc717 100644 --- a/frontend/app/(sidebar)/farms/add-farm-form.tsx +++ b/frontend/app/(sidebar)/farms/add-farm-form.tsx @@ -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) => Promise; 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>({ 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) => { 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 ( -
- {/* Form Section */} -
+
+ {/* ============================== + Start of Form Section + ============================== */} +
- + + {/* Farm Name Field */} - ( - - Latitude - - - - - - )} - /> - - ( - - Longitude - - - - - - )} - /> + {/* Coordinate Fields (Latitude & Longitude) */} +
+ ( + + Latitude + + + + + + )} + /> + ( + + Longitude + + + + + + )} + /> +
+ {/* Farm Type Selection */} + {/* Total Area Field */} Total Area (optional) - + - The total size of your farm + + The total size of your farm (e.g., "15 rai", "10 hectares"). + )} /> -
+ {/* Submit and Cancel Buttons */} +
@@ -173,12 +243,27 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) {
+ {/* ============================== + End of Form Section + ============================== */} - {/* Map Section */} -
- Farm Location - + {/* ============================== + Start of Map Section + - Renders an interactive map for coordinate selection. + ============================== */} +
+ Farm Location (Draw marker on map) +
+ +
+ + 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. +
+ {/* ============================== + End of Map Section + ============================== */}
); } diff --git a/frontend/app/(sidebar)/farms/farm-card.tsx b/frontend/app/(sidebar)/farms/farm-card.tsx index 85551e8..537d36b 100644 --- a/frontend/app/(sidebar)/farms/farm-card.tsx +++ b/frontend/app/(sidebar)/farms/farm-card.tsx @@ -75,7 +75,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {

Crops

-

{farm.crops}

+

{farm.Crops ? farm.Crops.length : 0}

diff --git a/frontend/app/(sidebar)/layout.tsx b/frontend/app/(sidebar)/layout.tsx index 709263a..e097aaf 100644 --- a/frontend/app/(sidebar)/layout.tsx +++ b/frontend/app/(sidebar)/layout.tsx @@ -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 ( - - - - -
-
- - - - -
-
- {children} - -
-
-
+ + + + + +
+
+ + + + +
+
+ {children} + +
+
+
+
); } diff --git a/frontend/components/google-map-with-drawing.tsx b/frontend/components/google-map-with-drawing.tsx index 0bf9289..0cdd310 100644 --- a/frontend/components/google-map-with-drawing.tsx +++ b/frontend/components/google-map-with-drawing.tsx @@ -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(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 ( - - setMap(map)} - > - {map && ( - - )} - - + <> + {/* Use props for map defaults */} + + + {/* Render controls only if drawingManager is available */} + {drawingManager && ( + + {/* Pass drawingManager to UndoRedoControl */} + + + )} + {/* The drawing controls (marker, polygon etc.) are added by useDrawingManager */} + ); }; diff --git a/frontend/components/map-component/undo-redo-control.tsx b/frontend/components/map-component/undo-redo-control.tsx new file mode 100644 index 0000000..6751c34 --- /dev/null +++ b/frontend/components/map-component/undo-redo-control.tsx @@ -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(false); + + useDrawingManagerEvents(drawingManager, overlaysShouldUpdateRef, dispatch); + useOverlaySnapshots(map, state, overlaysShouldUpdateRef); + + return ( +
+ + +
+ ); +}; diff --git a/frontend/components/map-component/undo-redo.ts b/frontend/components/map-component/undo-redo.ts new file mode 100644 index 0000000..b698b5b --- /dev/null +++ b/frontend/components/map-component/undo-redo.ts @@ -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, + dispatch: Dispatch +) { + useEffect(() => { + if (!drawingManager) return; + + const eventListeners: Array = []; + + 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 +) { + 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]); +} diff --git a/frontend/components/map-component/use-drawing-manager.tsx b/frontend/components/map-component/use-drawing-manager.tsx new file mode 100644 index 0000000..c85ab6a --- /dev/null +++ b/frontend/components/map-component/use-drawing-manager.tsx @@ -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(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; +} diff --git a/frontend/package.json b/frontend/package.json index 13595d5..4f3ca9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3ae2f45..1ecc653 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/types.ts b/frontend/types.ts index 6dc8ab6..71e1a59 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -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; + bounds?: google.maps.LatLngBoundsLiteral; +} + +export interface Overlay { + type: google.maps.drawing.OverlayType; + geometry: OverlayGeometry; + snapshot: Snapshot; +} + +export interface State { + past: Array>; + now: Array; + future: Array>; +} + +export enum DrawingActionKind { + SET_OVERLAY = "SET_OVERLAY", + UPDATE_OVERLAYS = "UPDATE_OVERLAYS", + UNDO = "UNDO", + REDO = "REDO", +} + +export interface ActionWithTypeOnly { + type: Exclude; +} + +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; +}