polygons
This commit is contained in:
@@ -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 ? (
|
||||
<Polygon
|
||||
positions={currentPolygon}
|
||||
pathOptions={{
|
||||
color: currentColor.value,
|
||||
fillColor: currentColor.value,
|
||||
fillOpacity: 0.3,
|
||||
weight: 2,
|
||||
dashArray: '5, 5'
|
||||
}}
|
||||
/>
|
||||
) : 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 (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="card text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Property Not Found</h3>
|
||||
<button onClick={() => navigate('/properties')} className="btn-primary">
|
||||
Back to Properties
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Property Details</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Property {id} details coming soon...</p>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/properties')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{property.name}</h1>
|
||||
<p className="text-gray-600 flex items-center gap-1">
|
||||
<MapPinIcon className="h-4 w-4" />
|
||||
{property.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsDrawing(!isDrawing)}
|
||||
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
{isDrawing ? 'Cancel Drawing' : 'Add Lawn Section'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Map */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div style={{ height: '600px', width: '100%' }}>
|
||||
<MapContainer
|
||||
center={[property.latitude, property.longitude]}
|
||||
zoom={19}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.esri.com/">Esri</a>'
|
||||
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
/>
|
||||
|
||||
<Marker position={[property.latitude, property.longitude]}>
|
||||
<Popup>{property.name}</Popup>
|
||||
</Marker>
|
||||
|
||||
{lawnSections.map((section) => (
|
||||
<Polygon
|
||||
key={section.id}
|
||||
positions={section.coordinates}
|
||||
pathOptions={{
|
||||
color: section.color.value,
|
||||
fillColor: section.color.value,
|
||||
fillOpacity: 0.4,
|
||||
weight: 2
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-center">
|
||||
<strong>{section.name}</strong><br />
|
||||
{section.area.toLocaleString()} sq ft<br />
|
||||
<button
|
||||
onClick={() => deleteLawnSection(section.id)}
|
||||
className="text-red-600 text-sm mt-2"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{isDrawing && (
|
||||
<PolygonDrawer
|
||||
isDrawing={isDrawing}
|
||||
onPolygonComplete={handlePolygonComplete}
|
||||
currentColor={currentColor}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{isDrawing && (
|
||||
<div className="p-4 bg-blue-50">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Drawing Mode:</strong> Click to add points. Double-click to complete polygon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Color Selector */}
|
||||
{isDrawing && (
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<SwatchIcon className="h-5 w-5" />
|
||||
Section Color
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SECTION_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => setCurrentColor(color)}
|
||||
className={`w-10 h-10 rounded border-2 ${
|
||||
currentColor.value === color.value ? 'border-gray-900' : 'border-gray-300'
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Property Summary */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Property Summary</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Total Sections:</span>
|
||||
<span className="font-medium">{lawnSections.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Total Area:</span>
|
||||
<span className="font-medium">{getTotalArea().toLocaleString()} sq ft</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lawn Sections */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Lawn Sections</h3>
|
||||
{lawnSections.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No sections yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lawnSections.map((section) => (
|
||||
<div key={section.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: section.color.value }}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{section.name}</p>
|
||||
<p className="text-xs text-gray-600">{section.area.toLocaleString()} sq ft</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteLawnSection(section.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name Modal */}
|
||||
{showNameModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-96">
|
||||
<h3 className="text-lg font-semibold mb-4">Name Your Lawn Section</h3>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={sectionName}
|
||||
onChange={(e) => setSectionName(e.target.value)}
|
||||
placeholder="e.g., Front Yard, Back Lawn"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: pendingSection?.color.value }}
|
||||
/>
|
||||
<span>{pendingSection?.area.toLocaleString()} sq ft</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button onClick={saveLawnSection} className="btn-primary flex-1">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNameModal(false);
|
||||
setPendingSection(null);
|
||||
setSectionName('');
|
||||
}}
|
||||
className="btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user