From 05011590e0b6386fc8e827d16685e35cab8bd814 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Thu, 21 Aug 2025 17:27:56 -0400 Subject: [PATCH] polygons --- frontend/package.json | 1 + .../src/pages/Properties/PropertyDetail.js | 388 +++++++++++++++++- 2 files changed, 383 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d3a96de..6d49b97 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "leaflet": "^1.9.4", "react-leaflet": "^4.2.1", "leaflet-draw": "^1.0.4", + "@turf/turf": "^6.5.0", "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.0.18", "react-hook-form": "^7.48.2", diff --git a/frontend/src/pages/Properties/PropertyDetail.js b/frontend/src/pages/Properties/PropertyDetail.js index 4c53f96..43cac9c 100644 --- a/frontend/src/pages/Properties/PropertyDetail.js +++ b/frontend/src/pages/Properties/PropertyDetail.js @@ -1,15 +1,391 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { MapContainer, TileLayer, Marker, Popup, Polygon, useMapEvents } from 'react-leaflet'; +import { Icon } from 'leaflet'; +import * as turf from '@turf/turf'; +import { + PlusIcon, + TrashIcon, + ArrowLeftIcon, + MapPinIcon, + SwatchIcon +} from '@heroicons/react/24/outline'; +import { propertiesAPI } from '../../services/api'; +import LoadingSpinner from '../../components/UI/LoadingSpinner'; +import toast from 'react-hot-toast'; +import 'leaflet/dist/leaflet.css'; + +// Fix for default markers +delete Icon.Default.prototype._getIconUrl; +Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', + iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', +}); + +// Colors for lawn sections +const SECTION_COLORS = [ + { name: 'Green', value: '#22c55e' }, + { name: 'Blue', value: '#3b82f6' }, + { name: 'Red', value: '#ef4444' }, + { name: 'Yellow', value: '#eab308' }, + { name: 'Purple', value: '#a855f7' }, + { name: 'Orange', value: '#f97316' }, +]; + +// Calculate area in square feet +const calculateAreaInSqFt = (coordinates) => { + try { + const polygon = turf.polygon([coordinates.map(coord => [coord[1], coord[0]])]); + const areaInSqMeters = turf.area(polygon); + return Math.round(areaInSqMeters * 10.7639); // Convert to sq ft + } catch (error) { + console.error('Area calculation error:', error); + return 0; + } +}; + +// Component for drawing polygons +function PolygonDrawer({ isDrawing, onPolygonComplete, currentColor }) { + const [currentPolygon, setCurrentPolygon] = useState([]); + + useMapEvents({ + click(e) { + if (!isDrawing) return; + + const newPoint = [e.latlng.lat, e.latlng.lng]; + setCurrentPolygon(prev => [...prev, newPoint]); + }, + dblclick() { + if (isDrawing && currentPolygon.length >= 3) { + onPolygonComplete(currentPolygon); + setCurrentPolygon([]); + } + } + }); + + return currentPolygon.length > 0 ? ( + + ) : null; +} const PropertyDetail = () => { const { id } = useParams(); - + const navigate = useNavigate(); + const [property, setProperty] = useState(null); + const [lawnSections, setLawnSections] = useState([]); + const [loading, setLoading] = useState(true); + const [isDrawing, setIsDrawing] = useState(false); + const [currentColor, setCurrentColor] = useState(SECTION_COLORS[0]); + const [showNameModal, setShowNameModal] = useState(false); + const [pendingSection, setPendingSection] = useState(null); + const [sectionName, setSectionName] = useState(''); + + useEffect(() => { + fetchPropertyDetails(); + }, [id]); + + const fetchPropertyDetails = async () => { + try { + setLoading(true); + const response = await propertiesAPI.getById(id); + console.log('Property details:', response); + setProperty(response.data.data); + } catch (error) { + console.error('Failed to fetch property:', error); + toast.error('Failed to load property'); + navigate('/properties'); + } finally { + setLoading(false); + } + }; + + const handlePolygonComplete = (coordinates) => { + if (coordinates.length < 3) { + toast.error('Polygon needs at least 3 points'); + return; + } + + const area = calculateAreaInSqFt([...coordinates, coordinates[0]]); + setPendingSection({ coordinates, color: currentColor, area }); + setShowNameModal(true); + setIsDrawing(false); + }; + + const saveLawnSection = () => { + if (!sectionName.trim()) { + toast.error('Please enter a section name'); + return; + } + + const newSection = { + id: Date.now(), + name: sectionName, + coordinates: pendingSection.coordinates, + color: pendingSection.color, + area: pendingSection.area + }; + + setLawnSections(prev => [...prev, newSection]); + toast.success(`${sectionName} section created!`); + + // Reset and cycle color + setSectionName(''); + setPendingSection(null); + setShowNameModal(false); + const nextIndex = (SECTION_COLORS.findIndex(c => c.value === currentColor.value) + 1) % SECTION_COLORS.length; + setCurrentColor(SECTION_COLORS[nextIndex]); + }; + + const deleteLawnSection = (sectionId) => { + if (window.confirm('Delete this lawn section?')) { + setLawnSections(prev => prev.filter(s => s.id !== sectionId)); + toast.success('Section deleted'); + } + }; + + const getTotalArea = () => { + return lawnSections.reduce((total, section) => total + section.area, 0); + }; + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + if (!property) { + return ( +
+
+

Property Not Found

+ +
+
+ ); + } + return (
-

Property Details

-
-

Property {id} details coming soon...

+ {/* Header */} +
+
+ +
+

{property.name}

+

+ + {property.address} +

+
+
+
+ +
+ {/* Map */} +
+
+
+ + + + + {property.name} + + + {lawnSections.map((section) => ( + + +
+ {section.name}
+ {section.area.toLocaleString()} sq ft
+ +
+
+
+ ))} + + {isDrawing && ( + + )} +
+
+ + {isDrawing && ( +
+

+ Drawing Mode: Click to add points. Double-click to complete polygon. +

+
+ )} +
+
+ + {/* Sidebar */} +
+ {/* Color Selector */} + {isDrawing && ( +
+

+ + Section Color +

+
+ {SECTION_COLORS.map((color) => ( +
+
+ )} + + {/* Property Summary */} +
+

Property Summary

+
+
+ Total Sections: + {lawnSections.length} +
+
+ Total Area: + {getTotalArea().toLocaleString()} sq ft +
+
+
+ + {/* Lawn Sections */} +
+

Lawn Sections

+ {lawnSections.length === 0 ? ( +

No sections yet.

+ ) : ( +
+ {lawnSections.map((section) => ( +
+
+
+
+

{section.name}

+

{section.area.toLocaleString()} sq ft

+
+
+ +
+ ))} +
+ )} +
+
+
+ + {/* Name Modal */} + {showNameModal && ( +
+
+

Name Your Lawn Section

+
+ setSectionName(e.target.value)} + placeholder="e.g., Front Yard, Back Lawn" + autoFocus + /> +
+
+ {pendingSection?.area.toLocaleString()} sq ft +
+
+
+ + +
+
+
+ )}
); };