From e4d3e14b48dc8d8a4cde87fcaf6d4e687b698091 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 29 Nov 2025 19:19:23 -0700 Subject: [PATCH 01/25] feat: add MUI icons dependency --- frontend/package-lock.json | 48 ++++++++++++++++++++++++-------------- frontend/package.json | 1 + 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8a611fc..ec9e6b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -762,6 +763,32 @@ "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.5.tgz", + "integrity": "sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.5", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", @@ -2603,9 +2630,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3919,21 +3946,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a13b4e3..dd5afa6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "react": "^19.1.1", "react-dom": "^19.1.1", From 0828908879a95ac7084fcf1dc467900f74229eac Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 29 Nov 2025 19:33:34 -0700 Subject: [PATCH 02/25] feat(input): add minimally functional coordinate file upload component --- frontend/src/components/AppRoutes.tsx | 5 + .../src/components/CoordinateFileUpload.tsx | 116 ++++++++++++++++++ .../src/pages/CoordinateFileUploadPage.tsx | 7 ++ frontend/src/services/fileUploadService.ts | 44 +++++++ 4 files changed, 172 insertions(+) create mode 100644 frontend/src/components/CoordinateFileUpload.tsx create mode 100644 frontend/src/pages/CoordinateFileUploadPage.tsx create mode 100644 frontend/src/services/fileUploadService.ts diff --git a/frontend/src/components/AppRoutes.tsx b/frontend/src/components/AppRoutes.tsx index 9e80a36..f363920 100644 --- a/frontend/src/components/AppRoutes.tsx +++ b/frontend/src/components/AppRoutes.tsx @@ -5,6 +5,7 @@ import SignupPage from '../pages/SignupPage'; import HomePage from '../pages/HomePage'; import { ProtectedRoute } from './ProtectedRoute'; import { PublicRoute } from './PublicRoute'; +import FileUploadPage from '../pages/FileUploadPage'; /** * AppRoutes component orchestrates all routing logic: @@ -35,6 +36,10 @@ export default function AppRoutes() { {/* Root "/" route - shows HomePage for authenticated, App.tsx for unauthenticated */} } fallback={} />} /> + {/* File upload route - allows users to upload coordinates */} + {/* Currently a public route, change to protected route. this is simply for easy testing. */} + } />} /> + {/* Auth pages - public, redirects authenticated users to home */} } />} /> } />} /> diff --git a/frontend/src/components/CoordinateFileUpload.tsx b/frontend/src/components/CoordinateFileUpload.tsx new file mode 100644 index 0000000..cf668cd --- /dev/null +++ b/frontend/src/components/CoordinateFileUpload.tsx @@ -0,0 +1,116 @@ +import { Box, Button, IconButton, Stack, Typography, CircularProgress } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import React from "react"; +import CloseIcon from "@mui/icons-material/Close"; +import { uploadCoordinateFile } from "../services/fileUploadService"; +import { COLORS } from "../styles/colors"; + +const VisuallyHiddenInput = styled('input')({ + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + bottom: 0, + left: 0, + whiteSpace: 'nowrap', + width: 1, +}); + +export default function CoordinateFileUpload() { + const [selectedFile, setSelectedFile] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files === null) { + throw Error("Error: No files selected"); + } + setSelectedFile(event.target.files[0]); + }; + + const handleFileSubmit = async () => { + if (!selectedFile) return; + + setIsLoading(true); + const formData = new FormData(); + formData.append('file', selectedFile); + + try { + const response = await uploadCoordinateFile(formData); + if (response) { + console.log("Success! File uploaded"); + setSelectedFile(null); + } + } catch (err: any) { + console.error(err); + } finally { + setIsLoading(false); + } + }; + + const handleClearFile = () => { + setSelectedFile(null); + }; + + return ( + + {!selectedFile ? ( + + ) : ( + + + {selectedFile.name} + + + + + + )} + + {selectedFile && ( + + )} + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/CoordinateFileUploadPage.tsx b/frontend/src/pages/CoordinateFileUploadPage.tsx new file mode 100644 index 0000000..ab6891c --- /dev/null +++ b/frontend/src/pages/CoordinateFileUploadPage.tsx @@ -0,0 +1,7 @@ +import CoordinateFileUpload from "../components/CoordinateFileUpload" + +export default function CoordinateFileUploadPage() { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/services/fileUploadService.ts b/frontend/src/services/fileUploadService.ts new file mode 100644 index 0000000..4ce2753 --- /dev/null +++ b/frontend/src/services/fileUploadService.ts @@ -0,0 +1,44 @@ +const DEFAULT_API = 'http://localhost:8000/api'; +const API_URL = ((import.meta.env.VITE_API_URL || DEFAULT_API) + '/data').replace(/\/$/, ''); + +async function handleResponse(response: Response) { + const text = await response.text(); + let data: any = null; + if (text) { + try { + data = JSON.parse(text); + } catch (e) { + data = text; + } + } + + if (!response.ok) { + throw data || { detail: response.statusText }; + } + + return data; +} + +export const uploadCoordinateFile = async (data: any): Promise => { + const response = await fetch(`${API_URL}/coordinate-file-upload/`, { + method: 'POST', + credentials: 'include', + body: data, + }); + + const result = await handleResponse(response); + + return result as any; +} + +export const uploadYieldFile = async (data: any): Promise => { + const response = await fetch(`${API_URL}/yield-file-upload/`, { + method: 'POST', + credentials: 'include', + body: data, + }); + + const result = await handleResponse(response); + + return result as any; +} \ No newline at end of file From 9cf32a20bea2ca533b3f364f1abf344d9169ed4f Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 1 Dec 2025 11:14:35 -0800 Subject: [PATCH 03/25] feat(input): improve styling and add yield file upload component --- .../src/components/CoordinateFileUpload.tsx | 25 +++- frontend/src/components/YieldFileUpload.tsx | 133 ++++++++++++++++++ .../src/pages/CoordinateFileUploadPage.tsx | 7 - 3 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/YieldFileUpload.tsx delete mode 100644 frontend/src/pages/CoordinateFileUploadPage.tsx diff --git a/frontend/src/components/CoordinateFileUpload.tsx b/frontend/src/components/CoordinateFileUpload.tsx index cf668cd..892a584 100644 --- a/frontend/src/components/CoordinateFileUpload.tsx +++ b/frontend/src/components/CoordinateFileUpload.tsx @@ -17,7 +17,8 @@ const VisuallyHiddenInput = styled('input')({ width: 1, }); -export default function CoordinateFileUpload() { +export default function CoordinateFileUpload(props: { onSelect?: (file: File | null) => void; onUploadComplete?: () => void }) { + const { onSelect, onUploadComplete } = props || {}; const [selectedFile, setSelectedFile] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); @@ -25,7 +26,9 @@ export default function CoordinateFileUpload() { if (event.target.files === null) { throw Error("Error: No files selected"); } - setSelectedFile(event.target.files[0]); + const f = event.target.files[0]; + setSelectedFile(f); + onSelect?.(f); }; const handleFileSubmit = async () => { @@ -40,6 +43,8 @@ export default function CoordinateFileUpload() { if (response) { console.log("Success! File uploaded"); setSelectedFile(null); + onSelect?.(null); + onUploadComplete?.(); } } catch (err: any) { console.error(err); @@ -50,6 +55,7 @@ export default function CoordinateFileUpload() { const handleClearFile = () => { setSelectedFile(null); + onSelect?.(null); }; return ( @@ -58,11 +64,17 @@ export default function CoordinateFileUpload() { @@ -105,7 +117,12 @@ export default function CoordinateFileUpload() { variant="contained" onClick={handleFileSubmit} disabled={isLoading} - sx={{ textTransform: 'none', fontSize: '1rem' }} + sx={{ + textTransform: 'none', + fontSize: '1rem', + backgroundColor: COLORS.indigo, + '&:hover': { backgroundColor: '#7a81ff' } + }} > {isLoading ? : null} {isLoading ? 'Uploading...' : 'Submit file'} diff --git a/frontend/src/components/YieldFileUpload.tsx b/frontend/src/components/YieldFileUpload.tsx new file mode 100644 index 0000000..e44ba62 --- /dev/null +++ b/frontend/src/components/YieldFileUpload.tsx @@ -0,0 +1,133 @@ +import { Box, Button, IconButton, Stack, Typography, CircularProgress } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import React from "react"; +import CloseIcon from "@mui/icons-material/Close"; +import { uploadYieldFile } from "../services/fileUploadService"; +import { COLORS } from "../styles/colors"; + +const VisuallyHiddenInput = styled('input')({ + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + bottom: 0, + left: 0, + whiteSpace: 'nowrap', + width: 1, +}); + +export default function YieldFileUpload(props: { onSelect?: (file: File | null) => void; onUploadComplete?: () => void }) { + const { onSelect, onUploadComplete } = props || {}; + const [selectedFile, setSelectedFile] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files === null) { + throw Error("Error: No files selected"); + } + const f = event.target.files[0]; + setSelectedFile(f); + onSelect?.(f); + }; + + const handleFileSubmit = async () => { + if (!selectedFile) return; + + setIsLoading(true); + const formData = new FormData(); + formData.append('file', selectedFile); + + try { + const response = await uploadYieldFile(formData); + if (response) { + console.log("Success! File uploaded"); + setSelectedFile(null); + onSelect?.(null); + onUploadComplete?.(); + } + } catch (err: any) { + console.error(err); + } finally { + setIsLoading(false); + } + }; + + const handleClearFile = () => { + setSelectedFile(null); + onSelect?.(null); + }; + + return ( + + {!selectedFile ? ( + + ) : ( + + + {selectedFile.name} + + + + + + )} + + {selectedFile && ( + + )} + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/CoordinateFileUploadPage.tsx b/frontend/src/pages/CoordinateFileUploadPage.tsx deleted file mode 100644 index ab6891c..0000000 --- a/frontend/src/pages/CoordinateFileUploadPage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import CoordinateFileUpload from "../components/CoordinateFileUpload" - -export default function CoordinateFileUploadPage() { - return ( - - ) -} \ No newline at end of file From 08240d03f548793e7bf6ceefdd48d4e153623ec1 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 1 Dec 2025 11:15:28 -0800 Subject: [PATCH 04/25] feat(input): add optimization constraints and field data inputs, create cohesive input modal --- frontend/src/components/FarmBiocharForm.tsx | 399 ++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 frontend/src/components/FarmBiocharForm.tsx diff --git a/frontend/src/components/FarmBiocharForm.tsx b/frontend/src/components/FarmBiocharForm.tsx new file mode 100644 index 0000000..eed18d4 --- /dev/null +++ b/frontend/src/components/FarmBiocharForm.tsx @@ -0,0 +1,399 @@ +import React from 'react'; +import { + Box, + Button, + TextField, + Select, + MenuItem, + Stack, + Typography, + IconButton, + Accordion, + AccordionSummary, + AccordionDetails, + + Dialog, + DialogTitle, + DialogContent, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import CloseIcon from '@mui/icons-material/Close'; +import { COLORS } from '../styles/colors'; +import CoordinateFileUpload from './CoordinateFileUpload'; +import YieldFileUpload from './YieldFileUpload'; + +type PriceUnit = '$/ton' | '$/kg' | '$/bushel'; + +interface FieldEntry { + id: string; + cropType: string; // Corn, Wheat, Soy, Barley, Other + customCrop?: string; + price: number | ''; + unit: PriceUnit; +} + +const DEFAULT_FIELD = (): FieldEntry => ({ + id: String(Date.now()) + Math.random().toString(36).slice(2, 9), + cropType: 'Wheat', + customCrop: '', + price: '', + unit: '$/ton', +}); + +export default function FarmBiocharForm() { + const [fields, setFields] = React.useState([DEFAULT_FIELD()]); + const [globalMax, setGlobalMax] = React.useState(''); + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const addField = () => setFields(prev => [...prev, DEFAULT_FIELD()]); + + const removeField = (id: string) => setFields(prev => prev.filter(f => f.id !== id)); + + const updateField = (id: string, patch: Partial) => { + setFields(prev => prev.map(f => (f.id === id ? { ...f, ...patch } : f))); + }; + + // file upload state + const [coordUploaded, setCoordUploaded] = React.useState(false); + + const handleCoordSelect = (file: File | null) => { + // If a file is selected, mark coordinates as uploaded/available so the form can be submitted. + // This covers the common case where the user selects/uploads a file and we want the + // Submit button to enable immediately. If the parent uploader also calls + // `onUploadComplete`, that will also set this to true. + setCoordUploaded(!!file); + }; + + const handleCoordUploaded = () => { + setCoordUploaded(true); + }; + + const handleYieldSelect = () => { + // yield file is optional, no action needed on select + }; + + const handleYieldUploaded = () => { + // yield file is optional, no action needed on upload completion + }; + + const openModal = () => setIsModalOpen(true); + const closeModal = () => setIsModalOpen(false); + + const FormContent = () => ( + + {/* Title Section */} + + + Farm Configuration + + + Define your fields, crops, and biochar budget allocation. Upload coordinate data to define your farm boundaries. + + + + {/* Global Budget Section */} + + + Budget Settings + + + Set an optional global cap on total biochar spending. This limit will be applied across all fields when distributing budget for biochar applications. + + + setGlobalMax(e.target.value === '' ? '' : Number(e.target.value))} + size="small" + sx={{ + minWidth: 250, + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: COLORS.whiteLow }, + '&:hover fieldset': { borderColor: COLORS.indigo }, + }, + + // Helper text color + '& .MuiFormHelperText-root': { + color: COLORS.whiteMedium, + }, + + // Adornment color fixes + '& .MuiInputAdornment-root': { color: COLORS.whiteHigh }, + '& .MuiInputBase-inputAdornedStart': { color: COLORS.whiteHigh }, + '& .MuiInputBase-inputAdornedEnd': { color: COLORS.whiteHigh }, + + // Icon inside adornment + '& .MuiSvgIcon-root': { color: COLORS.whiteHigh }, + + // Label color + '& .MuiInputLabel-root': { + color: `${COLORS.whiteMedium} !important`, + }, + }} + helperText="Optional - leave blank for no limit" + /> + + + + + {/* Fields Section */} + + + Your Fields ({fields.length}) + + + Add all fields where you plan to apply biochar. For each field, specify the crop type and current selling price to help calculate potential revenue impacts. + + + + {fields.map((f, idx) => ( + + }> + + Field {idx + 1} — {f.cropType === 'Other' ? (f.customCrop || 'Other') : f.cropType} + + {f.price && ( + + ${f.price}/{f.unit} + + )} + + + + {/* Crop Type */} + + Crop type + + Choose the primary crop grown in this field. + + + + {f.cropType === 'Other' && ( + updateField(f.id, { customCrop: e.target.value })} + placeholder="e.g., Alfalfa, Oats" + sx={{ + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: COLORS.whiteLow }, + '&:hover fieldset': { borderColor: COLORS.indigo }, + }, + '& .MuiInputLabel-root': { color: `${COLORS.whiteMedium} !important` }, + }} + /> + )} + + + {/* Selling Price */} + + Selling price + + Current market price per unit + + + updateField(f.id, { price: e.target.value === '' ? '' : Number(e.target.value) })} + sx={{ + minWidth: 120, + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: COLORS.whiteLow }, + '&:hover fieldset': { borderColor: COLORS.indigo }, + }, + '& .MuiInputAdornment-root': { color: COLORS.whiteHigh }, + '& .MuiInputLabel-root': { color: `${COLORS.whiteMedium} !important` }, + }} + /> + + + + + {/* Remove Button */} + + { + e.stopPropagation(); + removeField(f.id); + }} + sx={{ color: 'error.light' }} + aria-label={`Remove field ${idx + 1}`} + > + + + + + + + ))} + + + + {/* Files Section */} + + + Upload Farm Data + + + Upload geographic coordinate data (required) to define your farm boundaries. You can also optionally upload yield data to improve calculations. Supported formats: Shapefile, GeoJSON, CSV, KML, and other standard formats. + + + + + + Coordinate file + + Required + + + + Upload a file containing your farm boundary coordinates. This is essential for accurate spatial analysis and field mapping. + + + + Accepted formats: Shapefile (.shp, .shx, .dbf), GeoJSON (.geojson), CSV (.csv), KML/KMZ (.kml, .kmz) + + + + + + Yield file + + Optional + + + + Upload historical yield data to enable yield-based calculations and recommendations. This helps predict potential ROI from biochar applications. + + + + Accepted formats: CSV (.csv), ISOXML (.xml), Shapefile (.shp), TXT (.txt) + + + + + + {/* Submit Section */} + + + Ready to proceed? Make sure you've selected all your fields and uploaded at least the coordinate file. + + + + {!coordUploaded && ( + + Upload coordinate file to enable submission + + )} + {coordUploaded && ( + + Ready to submit + + )} + + + + ); + + return ( + <> + {/* Modal Trigger Button */} + + + {/* Modal Dialog */} + + + + + + + + + + + + ); +} From b5667fb7c88b1da128a3e839bb18e4f988148e9d Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 1 Dec 2025 11:15:56 -0800 Subject: [PATCH 05/25] feat: alter app routes for manual testing --- frontend/src/components/AppRoutes.tsx | 28 ------------- frontend/src/pages/HomePage.tsx | 60 ++++++++++++++++++--------- frontend/src/pages/LandingPage.tsx | 57 ++++++++++++------------- frontend/src/types/fileUpload.ts | 1 + 4 files changed, 67 insertions(+), 79 deletions(-) create mode 100644 frontend/src/types/fileUpload.ts diff --git a/frontend/src/components/AppRoutes.tsx b/frontend/src/components/AppRoutes.tsx index f363920..8c83954 100644 --- a/frontend/src/components/AppRoutes.tsx +++ b/frontend/src/components/AppRoutes.tsx @@ -5,41 +5,13 @@ import SignupPage from '../pages/SignupPage'; import HomePage from '../pages/HomePage'; import { ProtectedRoute } from './ProtectedRoute'; import { PublicRoute } from './PublicRoute'; -import FileUploadPage from '../pages/FileUploadPage'; -/** - * AppRoutes component orchestrates all routing logic: - * - * ProtectedRoute: Only allows authenticated users - * - If loading: shows spinner - * - If NOT authenticated: shows fallback or renders nothing - * - If authenticated: renders the protected element - * - * PublicRoute: Only allows unauthenticated users - * - If loading: shows spinner - * - If authenticated: redirects to / - * - If NOT authenticated: renders the public element - * - * Route structure: - * "/" → Dynamic based on auth status - * - Authenticated: HomePage (protected) - * - Unauthenticated: App.tsx (landing page, public) - * "/login" -> PublicRoute -> LoginPage (unauthenticated users only) - * "/signup" -> PublicRoute -> SignupPage (unauthenticated users only) - * "*" (catch-all) -> Dynamic based on auth status - * - Authenticated: HomePage (protected) - * - Unauthenticated: App.tsx (landing page, public) - */ export default function AppRoutes() { return ( {/* Root "/" route - shows HomePage for authenticated, App.tsx for unauthenticated */} } fallback={} />} /> - {/* File upload route - allows users to upload coordinates */} - {/* Currently a public route, change to protected route. this is simply for easy testing. */} - } />} /> - {/* Auth pages - public, redirects authenticated users to home */} } />} /> } />} /> diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 09f5624..8265198 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,6 +1,8 @@ import { Box, Button, Typography, Container } from '@mui/material'; import { useNavigate } from 'react-router'; import { useAuth } from '../contexts/AuthContext'; +import FarmBiocharForm from '../components/FarmBiocharForm'; +import { COLORS } from '../styles/colors'; const HomePage = () => { const navigate = useNavigate(); @@ -16,34 +18,52 @@ const HomePage = () => { }; return ( - + - - Welcome, {user?.first_name || user?.username}! - + {/* Header: title + logout */} + + + + Welcome, {user?.first_name || user?.username}! + + + Manage your farm biochar applications and data below. + + - - You are successfully logged in to CharAI. - + + - + {/* Intro boilerplate */} + + + About this tool + + + This application helps you plan and manage biochar applications across your farm. Use the form below to define each field, specify crops and current selling prices, and upload geographic coordinate files that define your field boundaries. + + + After uploading your coordinate file (required) and any optional yield data, you can submit a request to estimate potential impacts and budget allocation for biochar application. + + + + {/* Farm configuration form placed below the intro */} + + + ); diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index c9cd32e..32c7f20 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,38 +1,33 @@ -import { useState } from "react"; -import { Box, Button, Typography } from "@mui/material"; +import { Box, Typography, Container } from "@mui/material"; +import FarmBiocharForm from "../components/FarmBiocharForm"; +import { COLORS } from "../styles/colors"; const LandingPage = () => { - const [count, setCount] = useState(0); - return ( - - - CharAI - - - - Frontend initialized with React, TypeScript, Vite, and Material UI - - - - + + + CharAI + + + Optimize your farm's biochar application with AI-powered recommendations + + + + + + ); } diff --git a/frontend/src/types/fileUpload.ts b/frontend/src/types/fileUpload.ts new file mode 100644 index 0000000..ece1858 --- /dev/null +++ b/frontend/src/types/fileUpload.ts @@ -0,0 +1 @@ +// this will be filled out when backend defines apis. \ No newline at end of file From a6c2364d675130c56cf1782231692a5b8058eefc Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 2 Dec 2025 13:39:08 -0800 Subject: [PATCH 06/25] feat(input): fix styling issues, refactor input modal into smaller components --- frontend/src/components/BudgetSettings.tsx | 52 +++ frontend/src/components/FarmBiocharForm.tsx | 318 ++---------------- frontend/src/components/FieldsList.tsx | 178 ++++++++++ frontend/src/components/FileUploadSection.tsx | 65 ++++ frontend/src/components/SubmitSection.tsx | 47 +++ 5 files changed, 375 insertions(+), 285 deletions(-) create mode 100644 frontend/src/components/BudgetSettings.tsx create mode 100644 frontend/src/components/FieldsList.tsx create mode 100644 frontend/src/components/FileUploadSection.tsx create mode 100644 frontend/src/components/SubmitSection.tsx diff --git a/frontend/src/components/BudgetSettings.tsx b/frontend/src/components/BudgetSettings.tsx new file mode 100644 index 0000000..db5b913 --- /dev/null +++ b/frontend/src/components/BudgetSettings.tsx @@ -0,0 +1,52 @@ +import { Box, TextField, Typography, InputAdornment } from '@mui/material'; +import { COLORS } from '../styles/colors'; + +interface BudgetSettingsProps { + globalMax: number | ''; + onChange: (value: number | '') => void; +} + +export default function BudgetSettings({ globalMax, onChange }: BudgetSettingsProps) { + return ( + + + Budget Settings + + + Set an optional global cap on total biochar spending. This limit will be applied across all fields when distributing budget for biochar applications. + + onChange(e.target.value === '' ? '' : Number(e.target.value))} + size="small" + slotProps={{ + input: { + startAdornment: ( + + $ + + ) + }, + }} + sx={{ + minWidth: 250, + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: COLORS.whiteLow }, + '&:hover fieldset': { borderColor: COLORS.indigo }, + }, + '& .MuiFormHelperText-root': { + color: COLORS.whiteMedium, + }, + '& .MuiSvgIcon-root': { color: COLORS.whiteHigh }, + '& .MuiInputLabel-root': { + color: `${COLORS.whiteMedium} !important`, + }, + }} + helperText="Optional - leave blank for no limit" + /> + + ); +} diff --git a/frontend/src/components/FarmBiocharForm.tsx b/frontend/src/components/FarmBiocharForm.tsx index eed18d4..890e7bc 100644 --- a/frontend/src/components/FarmBiocharForm.tsx +++ b/frontend/src/components/FarmBiocharForm.tsx @@ -2,44 +2,26 @@ import React from 'react'; import { Box, Button, - TextField, - Select, - MenuItem, - Stack, Typography, IconButton, - Accordion, - AccordionSummary, - AccordionDetails, - Dialog, DialogTitle, DialogContent, } from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteIcon from '@mui/icons-material/Delete'; import CloseIcon from '@mui/icons-material/Close'; import { COLORS } from '../styles/colors'; -import CoordinateFileUpload from './CoordinateFileUpload'; -import YieldFileUpload from './YieldFileUpload'; - -type PriceUnit = '$/ton' | '$/kg' | '$/bushel'; - -interface FieldEntry { - id: string; - cropType: string; // Corn, Wheat, Soy, Barley, Other - customCrop?: string; - price: number | ''; - unit: PriceUnit; -} +import BudgetSettings from './BudgetSettings'; +import FieldsList from './FieldsList'; +import type { FieldEntry } from './FieldsList'; +import FileUploadSection from './FileUploadSection'; +import SubmitSection from './SubmitSection'; const DEFAULT_FIELD = (): FieldEntry => ({ id: String(Date.now()) + Math.random().toString(36).slice(2, 9), cropType: 'Wheat', customCrop: '', price: '', - unit: '$/ton', + unit: 'bushel', }); export default function FarmBiocharForm() { @@ -93,268 +75,34 @@ export default function FarmBiocharForm() { - {/* Global Budget Section */} - - - Budget Settings - - - Set an optional global cap on total biochar spending. This limit will be applied across all fields when distributing budget for biochar applications. - - - setGlobalMax(e.target.value === '' ? '' : Number(e.target.value))} - size="small" - sx={{ - minWidth: 250, - '& .MuiOutlinedInput-root': { - color: COLORS.whiteHigh, - '& fieldset': { borderColor: COLORS.whiteLow }, - '&:hover fieldset': { borderColor: COLORS.indigo }, - }, - - // Helper text color - '& .MuiFormHelperText-root': { - color: COLORS.whiteMedium, - }, - - // Adornment color fixes - '& .MuiInputAdornment-root': { color: COLORS.whiteHigh }, - '& .MuiInputBase-inputAdornedStart': { color: COLORS.whiteHigh }, - '& .MuiInputBase-inputAdornedEnd': { color: COLORS.whiteHigh }, - - // Icon inside adornment - '& .MuiSvgIcon-root': { color: COLORS.whiteHigh }, - - // Label color - '& .MuiInputLabel-root': { - color: `${COLORS.whiteMedium} !important`, - }, - }} - helperText="Optional - leave blank for no limit" - /> - - - - - {/* Fields Section */} - - - Your Fields ({fields.length}) - - - Add all fields where you plan to apply biochar. For each field, specify the crop type and current selling price to help calculate potential revenue impacts. - - - - {fields.map((f, idx) => ( - - }> - - Field {idx + 1} — {f.cropType === 'Other' ? (f.customCrop || 'Other') : f.cropType} - - {f.price && ( - - ${f.price}/{f.unit} - - )} - - - - {/* Crop Type */} - - Crop type - - Choose the primary crop grown in this field. - - - - {f.cropType === 'Other' && ( - updateField(f.id, { customCrop: e.target.value })} - placeholder="e.g., Alfalfa, Oats" - sx={{ - '& .MuiOutlinedInput-root': { - color: COLORS.whiteHigh, - '& fieldset': { borderColor: COLORS.whiteLow }, - '&:hover fieldset': { borderColor: COLORS.indigo }, - }, - '& .MuiInputLabel-root': { color: `${COLORS.whiteMedium} !important` }, - }} - /> - )} - - - {/* Selling Price */} - - Selling price - - Current market price per unit - - - updateField(f.id, { price: e.target.value === '' ? '' : Number(e.target.value) })} - sx={{ - minWidth: 120, - '& .MuiOutlinedInput-root': { - color: COLORS.whiteHigh, - '& fieldset': { borderColor: COLORS.whiteLow }, - '&:hover fieldset': { borderColor: COLORS.indigo }, - }, - '& .MuiInputAdornment-root': { color: COLORS.whiteHigh }, - '& .MuiInputLabel-root': { color: `${COLORS.whiteMedium} !important` }, - }} - /> - - - - - {/* Remove Button */} - - { - e.stopPropagation(); - removeField(f.id); - }} - sx={{ color: 'error.light' }} - aria-label={`Remove field ${idx + 1}`} - > - - - - - - - ))} - - - - {/* Files Section */} - - - Upload Farm Data - - - Upload geographic coordinate data (required) to define your farm boundaries. You can also optionally upload yield data to improve calculations. Supported formats: Shapefile, GeoJSON, CSV, KML, and other standard formats. - - - - - - Coordinate file - - Required - - - - Upload a file containing your farm boundary coordinates. This is essential for accurate spatial analysis and field mapping. - - - - Accepted formats: Shapefile (.shp, .shx, .dbf), GeoJSON (.geojson), CSV (.csv), KML/KMZ (.kml, .kmz) - - - - - - Yield file - - Optional - - - - Upload historical yield data to enable yield-based calculations and recommendations. This helps predict potential ROI from biochar applications. - - - - Accepted formats: CSV (.csv), ISOXML (.xml), Shapefile (.shp), TXT (.txt) - - - - + {/* Budget Settings */} + + + {/* Fields List */} + + + {/* File Upload Section */} + {/* Submit Section */} - - - Ready to proceed? Make sure you've selected all your fields and uploaded at least the coordinate file. - - - - {!coordUploaded && ( - - Upload coordinate file to enable submission - - )} - {coordUploaded && ( - - Ready to submit - - )} - - + { + const payload = { globalMax, fields }; + console.log('Submit payload', payload); + alert('Form submitted! Check console for payload details.'); + }} + /> ); @@ -373,7 +121,7 @@ export default function FarmBiocharForm() { void; + onRemoveField: (id: string) => void; + onUpdateField: (id: string, patch: Partial) => void; +} + +export default function FieldsList({ fields, onAddField, onRemoveField, onUpdateField }: FieldsListProps) { + return ( + + + + + Your Fields ({fields.length}) + + + Add all fields where you plan to apply biochar. For each field, specify the crop type and current selling price to help calculate potential revenue impacts. + + + + + + + {fields.map((f, idx) => ( + + }> + + Field {idx + 1} — {f.cropType === 'Other' ? (f.customCrop || 'Other') : f.cropType} + + {f.price && ( + + ${f.price}/{f.unit} + + )} + + + + {/* Crop Type */} + + Crop type + + Choose the primary crop grown in this field. + + + + {f.cropType === 'Other' && ( + onUpdateField(f.id, { customCrop: e.target.value })} + placeholder="e.g., Alfalfa, Oats" + sx={{ + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: COLORS.whiteLow }, + '&:hover fieldset': { borderColor: COLORS.indigo }, + }, + '& .MuiInputLabel-root': { color: `${COLORS.whiteMedium} !important` }, + }} + /> + )} + + + {/* Selling Price */} + + Selling price + + Current market price per unit + + + onUpdateField(f.id, { price: e.target.value === '' ? '' : Number(e.target.value) })} + sx={{ + minWidth: 120, + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: COLORS.whiteLow }, + '&:hover fieldset': { borderColor: COLORS.indigo }, + }, + '& .MuiInputLabel-root': { + color: `${COLORS.whiteMedium} !important`, + }, + }} + slotProps={{ + input: { + startAdornment: ( + + $ + + ) + }, + }} + /> + + + + + {/* Remove Button */} + + { + e.stopPropagation(); + onRemoveField(f.id); + }} + sx={{ color: 'error.light' }} + aria-label={`Remove field ${idx + 1}`} + > + + + + + + + ))} + + + ); +} diff --git a/frontend/src/components/FileUploadSection.tsx b/frontend/src/components/FileUploadSection.tsx new file mode 100644 index 0000000..ad2a6b9 --- /dev/null +++ b/frontend/src/components/FileUploadSection.tsx @@ -0,0 +1,65 @@ +import { Box, Stack, Typography } from '@mui/material'; +import { COLORS } from '../styles/colors'; +import CoordinateFileUpload from './CoordinateFileUpload'; +import YieldFileUpload from './YieldFileUpload'; + +interface FileUploadSectionProps { + onCoordSelect: (file: File | null) => void; + onCoordUploaded: () => void; + onYieldSelect: () => void; + onYieldUploaded: () => void; +} + +export default function FileUploadSection({ + onCoordSelect, + onCoordUploaded, + onYieldSelect, + onYieldUploaded, +}: FileUploadSectionProps) { + return ( + + + Upload Farm Data + + + Upload geographic coordinate data (required) to define your farm boundaries. You can also optionally upload yield data to improve calculations. Supported formats: Shapefile, GeoJSON, CSV, KML, and other standard formats. + + + + {/* Coordinate File Box */} + + + Coordinate file + + Required + + + + Upload a file containing your farm boundary coordinates. This is essential for accurate spatial analysis and field mapping. + + + + Accepted formats: Shapefile (.shp, .shx, .dbf), GeoJSON (.geojson), CSV (.csv), KML/KMZ (.kml, .kmz) + + + + {/* Yield File Box */} + + + Yield file + + Optional + + + + Upload historical yield data to enable yield-based calculations and recommendations. This helps predict potential ROI from biochar applications. + + + + Accepted formats: CSV (.csv), ISOXML (.xml), Shapefile (.shp), TXT (.txt) + + + + + ); +} diff --git a/frontend/src/components/SubmitSection.tsx b/frontend/src/components/SubmitSection.tsx new file mode 100644 index 0000000..902f9ed --- /dev/null +++ b/frontend/src/components/SubmitSection.tsx @@ -0,0 +1,47 @@ +import { Box, Button, Stack, Typography } from '@mui/material'; +import { COLORS } from '../styles/colors'; + +interface SubmitSectionProps { + coordUploaded: boolean; + onSubmit: () => void; +} + +export default function SubmitSection({ coordUploaded, onSubmit }: SubmitSectionProps) { + return ( + + + Ready to proceed? Make sure you've selected all your fields and uploaded at least the coordinate file. + + + + {!coordUploaded && ( + + Upload coordinate file to enable submission + + )} + {coordUploaded && ( + + Ready to submit + + )} + + + ); +} From 3baa970de8f8477a13b6fd8434cf732e5c716d5d Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 2 Dec 2025 14:12:29 -0800 Subject: [PATCH 07/25] feat(output): add Leaflet and React Leaflet dependencies --- frontend/package-lock.json | 33 +++++++++++++++++++++++++++++++++ frontend/package.json | 2 ++ 2 files changed, 35 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec9e6b6..60224ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,8 +12,10 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", + "leaflet": "^1.9.4", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-leaflet": "^5.0.0", "react-router": "^7.9.5" }, "devDependencies": { @@ -1067,6 +1069,17 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.41", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz", @@ -2704,6 +2717,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3363,6 +3382,20 @@ "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index dd5afa6..e3a1ede 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,8 +14,10 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", + "leaflet": "^1.9.4", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-leaflet": "^5.0.0", "react-router": "^7.9.5" }, "devDependencies": { From 41dde4ae5975efe4f9463bcec9cb46c6bb3b60c6 Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 2 Dec 2025 14:44:25 -0800 Subject: [PATCH 08/25] feat(output): edit Leaflet and React Leaflet dependencies, add types --- frontend/index.html | 7 +++++++ frontend/package-lock.json | 32 +++++++++++++++++++++++++------- frontend/package.json | 7 ++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 65f5e81..2b0e1ac 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,13 @@ + + + CharAI diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60224ee..9973745 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,13 +13,14 @@ "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "leaflet": "^1.9.4", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-leaflet": "^5.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0-rc.2", "react-router": "^7.9.5" }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/leaflet": "^1.9.21", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -1388,6 +1389,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1395,6 +1403,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", @@ -3383,12 +3401,12 @@ "license": "MIT" }, "node_modules/react-leaflet": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", - "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "version": "5.0.0-rc.2", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0-rc.2.tgz", + "integrity": "sha512-1xQGYG9mEIW+nfkQhqgHImwUuB1UDlnzYFSzv6PrBFDBeYrFmv0BbpwpNAFdJg/UQ2yz5UZSL7ZwlUxjwb8MZw==", "license": "Hippocratic-2.1", "dependencies": { - "@react-leaflet/core": "^3.0.0" + "@react-leaflet/core": "^3.0.0-rc.2" }, "peerDependencies": { "leaflet": "^1.9.0", diff --git a/frontend/package.json b/frontend/package.json index e3a1ede..73c6311 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,13 +15,14 @@ "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "leaflet": "^1.9.4", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-leaflet": "^5.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0-rc.2", "react-router": "^7.9.5" }, "devDependencies": { "@eslint/js": "^9.36.0", + "@types/leaflet": "^1.9.21", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", From 04ba2b940858ff0f0ab74d754b53ad113b9fde55 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 4 Dec 2025 14:19:58 -0800 Subject: [PATCH 09/25] feat(output): minimal React Leaflet JS map --- frontend/src/components/BiocharMap.tsx | 41 ++++++++++++++++++++++++++ frontend/src/index.css | 1 - frontend/src/pages/LandingPage.tsx | 5 ++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/BiocharMap.tsx diff --git a/frontend/src/components/BiocharMap.tsx b/frontend/src/components/BiocharMap.tsx new file mode 100644 index 0000000..9ea8756 --- /dev/null +++ b/frontend/src/components/BiocharMap.tsx @@ -0,0 +1,41 @@ +import { MapContainer, TileLayer, Polygon } from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import type { LatLngTuple } from "leaflet"; + +const BiocharMap = () => { + // Mock field polygon (replace with parsed CSV or backend) + const fieldCoords: LatLngTuple[] = [ + [43.612, -116.391], + [43.613, -116.391], + [43.613, -116.389], + [43.612, -116.389] + ]; + + return ( +
+ + {/* ESRI Satellite Imagery */} + + + {/* Field boundary polygon */} + + +
+ ); +}; + +export default BiocharMap; diff --git a/frontend/src/index.css b/frontend/src/index.css index 1287ec1..7ace972 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -64,7 +64,6 @@ button:focus-visible { @media (prefers-color-scheme: light) { :root { color: #213547; - background-color: #ffffff; } a:hover { color: #747bff; diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 32c7f20..fd7517e 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,6 +1,7 @@ import { Box, Typography, Container } from "@mui/material"; import FarmBiocharForm from "../components/FarmBiocharForm"; import { COLORS } from "../styles/colors"; +import BiocharMap from "../components/BiocharMap"; const LandingPage = () => { return ( @@ -26,6 +27,10 @@ const LandingPage = () => { + {/* Test prescription map component */} + + + ); From b064fab4ebb095a3aaf7c21d01048ae9b5eb951f Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 4 Dec 2025 15:45:52 -0800 Subject: [PATCH 10/25] feat(output): create minimal manual coordinate upload entry --- frontend/src/components/FileUploadSection.tsx | 4 +- .../src/components/InteractiveFarmMap.tsx | 59 +++++++++++++++++++ .../src/components/ManualCoordinateUpload.tsx | 59 +++++++++++++++++++ frontend/src/pages/LandingPage.tsx | 4 -- 4 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/InteractiveFarmMap.tsx create mode 100644 frontend/src/components/ManualCoordinateUpload.tsx diff --git a/frontend/src/components/FileUploadSection.tsx b/frontend/src/components/FileUploadSection.tsx index ad2a6b9..3a848e4 100644 --- a/frontend/src/components/FileUploadSection.tsx +++ b/frontend/src/components/FileUploadSection.tsx @@ -2,6 +2,7 @@ import { Box, Stack, Typography } from '@mui/material'; import { COLORS } from '../styles/colors'; import CoordinateFileUpload from './CoordinateFileUpload'; import YieldFileUpload from './YieldFileUpload'; +import ManualCoordinateUpload from './ManualCoordinateUpload'; interface FileUploadSectionProps { onCoordSelect: (file: File | null) => void; @@ -38,9 +39,10 @@ export default function FileUploadSection({ Upload a file containing your farm boundary coordinates. This is essential for accurate spatial analysis and field mapping. - + Accepted formats: Shapefile (.shp, .shx, .dbf), GeoJSON (.geojson), CSV (.csv), KML/KMZ (.kml, .kmz) + {/* Yield File Box */} diff --git a/frontend/src/components/InteractiveFarmMap.tsx b/frontend/src/components/InteractiveFarmMap.tsx new file mode 100644 index 0000000..7173407 --- /dev/null +++ b/frontend/src/components/InteractiveFarmMap.tsx @@ -0,0 +1,59 @@ +import React, { useState } from "react"; +import { MapContainer, TileLayer, Marker, useMapEvent, Polygon } from "react-leaflet"; +import { type LatLngLiteral } from "leaflet"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; +import { Box, Button } from "@mui/material"; + +// temporary workaround for marker icon clash between Vite and React Leaflet +delete L.Icon.Default.prototype._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png", + iconUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png", +}); + +interface ClickHandlerProps { + markers: LatLngLiteral[]; + setMarkers: React.Dispatch>; +} + +const ClickHandler: React.FC = ({ markers, setMarkers }) => { + useMapEvent("click", (e) => { + setMarkers([...markers, e.latlng]); + }); + return null; +}; + +export default function FarmMap() { + const [markers, setMarkers] = useState([]); + + const handleClearMarkers = () => { + setMarkers([]); + } + + return ( + + + + + + + + + {markers.map((position, idx) => ( + + ))} + {markers.length >= 3 && } + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/components/ManualCoordinateUpload.tsx new file mode 100644 index 0000000..b6a19e9 --- /dev/null +++ b/frontend/src/components/ManualCoordinateUpload.tsx @@ -0,0 +1,59 @@ +import { Box, Button, Dialog, DialogContent, DialogTitle, IconButton, Typography } from "@mui/material"; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { COLORS } from "../styles/colors"; +import React from "react"; +import InteractiveFarmMap from "./InteractiveFarmMap"; + +export default function ManualCoordinateUpload() { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const openModal = () => setIsModalOpen(true); + const closeModal = () => setIsModalOpen(false); + + const FormContent = () => { + return ( + + Hello World. + + + ) + } + + return ( + <> + + + + + + + Back + + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index fd7517e..cd7f2c3 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -27,10 +27,6 @@ const LandingPage = () => { - {/* Test prescription map component */} - - - ); From daea9012fe1b9a359a9c9e87508753407cb37701 Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 4 Dec 2025 18:58:42 -0800 Subject: [PATCH 11/25] feat(output): add minimal prescription map viewer --- .../src/components/ManualCoordinateUpload.tsx | 8 ++ .../src/components/PrescriptionMapViewer.tsx | 106 ++++++++++++++++++ frontend/src/pages/LandingPage.tsx | 1 - frontend/src/pages/PrescriptionsPage.tsx | 7 ++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/PrescriptionMapViewer.tsx create mode 100644 frontend/src/pages/PrescriptionsPage.tsx diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/components/ManualCoordinateUpload.tsx index b6a19e9..fb50fb5 100644 --- a/frontend/src/components/ManualCoordinateUpload.tsx +++ b/frontend/src/components/ManualCoordinateUpload.tsx @@ -10,11 +10,19 @@ export default function ManualCoordinateUpload() { const openModal = () => setIsModalOpen(true); const closeModal = () => setIsModalOpen(false); + const handleSubmit = () => { + } + const FormContent = () => { return ( Hello World. + ) } diff --git a/frontend/src/components/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer.tsx new file mode 100644 index 0000000..7e5e13c --- /dev/null +++ b/frontend/src/components/PrescriptionMapViewer.tsx @@ -0,0 +1,106 @@ +import { Box, Typography } from "@mui/material"; +import type { FeatureCollection } from 'geojson'; +import L from "leaflet"; +import type { LatLngExpression } from "leaflet"; +import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; +import 'leaflet/dist/leaflet.css'; +import { COLORS } from '../styles/colors'; +import React from "react"; + +interface PrescriptionMapViewerProps { + /** A GeoJSON FeatureCollection from the backend. Expect the outer polygon (farm boundary) + * and inner polygons (application zones) as Features with properties like + * { applicationRate: number, priority: string } + */ + data?: FeatureCollection | null; + height?: number | string; + width?: number | string; +} + +const PRIORITY_COLORS: Record = { + 'highest-to-high': '#7b1fa2', // deep purple + 'high-to-med-high': '#e91e63', + 'med-high-to-med': '#ff9800', + 'med-to-low': '#ffc107', + 'low': '#c8e6c9', +}; + +function featureStyle(feature: any) { + const props = feature?.properties || {}; + const priority: string = (props.priority || props.priorityRange || '').toLowerCase(); + const rate = props.applicationRate ?? props.rate ?? null; + + const fillColor = PRIORITY_COLORS[priority] ?? (rate ? `rgba(100,100,255, ${Math.min(0.85, 0.25 + (rate / 200))})` : '#90caf9'); + + return { + color: '#222', + weight: 1, + opacity: 0.9, + fillColor, + fillOpacity: 0.6, + } as L.PathOptions; +} + +export default function PrescriptionMapViewer({ data, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { + // default center if no data + const defaultCenter: LatLngExpression = [44.5, -110]; + + // compute bounds from GeoJSON if provided + const bounds = data ? L.geoJSON(data).getBounds() : undefined; + + const onEachFeature = (feature: any, layer: L.Layer) => { + const props = feature?.properties || {}; + const applicationRate = props.applicationRate ?? props.rate ?? 'n/a'; + const priority = props.priority || props.priorityRange || 'n/a'; + + const html = `
Priority: ${priority}
Rate: ${applicationRate}
`; + if ((layer as any).bindPopup) { + (layer as any).bindPopup(html); + } + + // highlight on hover + layer.on('mouseover', () => { + (layer as any).setStyle && (layer as any).setStyle({ weight: 2.5, fillOpacity: 0.8 }); + }); + layer.on('mouseout', () => { + (layer as any).setStyle && (layer as any).setStyle(featureStyle(feature)); + }); + }; + + // helper component to fit map bounds when data changes + function FitBounds({ bounds }: { bounds?: L.LatLngBounds }) { + const map = useMap(); + React.useEffect(() => { + if (bounds && map) { + map.fitBounds(bounds.pad(0.05)); + } + }, [map, bounds]); + return null; + } + + return ( + + {!data && ( + + No prescription data available. Upload a GeoJSON from the backend to preview application zones. + + )} + + + + + + {data && ( + <> + featureStyle(feature)} onEachFeature={onEachFeature} /> + + + )} + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index cd7f2c3..32c7f20 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,7 +1,6 @@ import { Box, Typography, Container } from "@mui/material"; import FarmBiocharForm from "../components/FarmBiocharForm"; import { COLORS } from "../styles/colors"; -import BiocharMap from "../components/BiocharMap"; const LandingPage = () => { return ( diff --git a/frontend/src/pages/PrescriptionsPage.tsx b/frontend/src/pages/PrescriptionsPage.tsx new file mode 100644 index 0000000..631f253 --- /dev/null +++ b/frontend/src/pages/PrescriptionsPage.tsx @@ -0,0 +1,7 @@ +import PrescriptionMapViewer from "../components/PrescriptionMapViewer"; + +export default function PrescriptionsPage() { + return ( + + ) +} \ No newline at end of file From 0ec609df3b1e5e57eb9b9ad381a14c38f4f2fe3a Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 4 Dec 2025 19:20:31 -0800 Subject: [PATCH 12/25] feat(test): add routing and sample coordinate data for manual testing --- frontend/src/components/AppRoutes.tsx | 6 +- frontend/src/components/Header.tsx | 7 + .../src/components/PrescriptionMapViewer.tsx | 13 +- frontend/src/samplePrescriptionData.ts | 191 ++++++++++++++++++ 4 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 frontend/src/samplePrescriptionData.ts diff --git a/frontend/src/components/AppRoutes.tsx b/frontend/src/components/AppRoutes.tsx index 8c83954..e8f3de9 100644 --- a/frontend/src/components/AppRoutes.tsx +++ b/frontend/src/components/AppRoutes.tsx @@ -5,17 +5,21 @@ import SignupPage from '../pages/SignupPage'; import HomePage from '../pages/HomePage'; import { ProtectedRoute } from './ProtectedRoute'; import { PublicRoute } from './PublicRoute'; +import PrescriptionsPage from '../pages/PrescriptionsPage'; export default function AppRoutes() { return ( {/* Root "/" route - shows HomePage for authenticated, App.tsx for unauthenticated */} } fallback={} />} /> - + {/* Auth pages - public, redirects authenticated users to home */} } />} /> } />} /> + {/* Temporary public pages for testing */} + } />} /> + {/* Catch-all for unmatched routes */} } fallback={} />} /> diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 8574713..c291d2d 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -64,6 +64,13 @@ const Header = () => { > Sign up + )} diff --git a/frontend/src/components/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer.tsx index 7e5e13c..bb1424c 100644 --- a/frontend/src/components/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer.tsx @@ -6,6 +6,7 @@ import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; import 'leaflet/dist/leaflet.css'; import { COLORS } from '../styles/colors'; import React from "react"; +import { samplePrescriptionData } from "../samplePrescriptionData"; interface PrescriptionMapViewerProps { /** A GeoJSON FeatureCollection from the backend. Expect the outer polygon (farm boundary) @@ -18,11 +19,11 @@ interface PrescriptionMapViewerProps { } const PRIORITY_COLORS: Record = { - 'highest-to-high': '#7b1fa2', // deep purple - 'high-to-med-high': '#e91e63', - 'med-high-to-med': '#ff9800', - 'med-to-low': '#ffc107', - 'low': '#c8e6c9', + "high": "#d7191c", + "medium-high": "#f58634", + "medium": "#f9d423", + "medium-low": "#a6d96a", + "low": "#1a9641", }; function featureStyle(feature: any) { @@ -41,7 +42,7 @@ function featureStyle(feature: any) { } as L.PathOptions; } -export default function PrescriptionMapViewer({ data, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { +export default function PrescriptionMapViewer({ data = samplePrescriptionData, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { // default center if no data const defaultCenter: LatLngExpression = [44.5, -110]; diff --git a/frontend/src/samplePrescriptionData.ts b/frontend/src/samplePrescriptionData.ts new file mode 100644 index 0000000..7de2c5a --- /dev/null +++ b/frontend/src/samplePrescriptionData.ts @@ -0,0 +1,191 @@ +import type { FeatureCollection } from 'geojson'; + +export const samplePrescriptionData: FeatureCollection = { + type: "FeatureCollection", + features: [ + // ----------------------------------------------------- + // 1. Outer Farm Boundary (small, irregular polygon) + // ----------------------------------------------------- + { + type: "Feature", + properties: { type: "boundary" }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24210, 43.60220], + [-116.23890, 43.60225], + [-116.23840, 43.60010], + [-116.23910, 43.59830], + [-116.24180, 43.59820], + [-116.24240, 43.59960], + [-116.24210, 43.60220] + ]] + } + }, + + // ----------------------------------------------------- + // Inner polygons perfectly fill the boundary + // These are arranged like a puzzle so no gaps/overlaps + // ----------------------------------------------------- + + // 2. Zone A + { + type: "Feature", + properties: { priority: "high", applicationRate: 160 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24210, 43.60220], + [-116.24070, 43.60222], + [-116.24060, 43.60130], + [-116.24205, 43.60128], + [-116.24210, 43.60220] + ]] + } + }, + + // 3. Zone B + { + type: "Feature", + properties: { priority: "medium", applicationRate: 120 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24070, 43.60222], + [-116.23890, 43.60225], + [-116.23888, 43.60120], + [-116.24060, 43.60130], + [-116.24070, 43.60222] + ]] + } + }, + + // 4. Zone C + { + type: "Feature", + properties: { priority: "low", applicationRate: 70 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24205, 43.60128], + [-116.24060, 43.60130], + [-116.24055, 43.60060], + [-116.24200, 43.60055], + [-116.24205, 43.60128] + ]] + } + }, + + // 5. Zone D + { + type: "Feature", + properties: { priority: "medium", applicationRate: 95 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24060, 43.60130], + [-116.23888, 43.60120], + [-116.23880, 43.60070], + [-116.24055, 43.60060], + [-116.24060, 43.60130] + ]] + } + }, + + // 6. Zone E + { + type: "Feature", + properties: { priority: "high", applicationRate: 180 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24200, 43.60055], + [-116.24055, 43.60060], + [-116.24052, 43.59990], + [-116.24195, 43.59988], + [-116.24200, 43.60055] + ]] + } + }, + + // 7. Zone F + { + type: "Feature", + properties: { priority: "medium-low", applicationRate: 85 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24055, 43.60060], + [-116.23880, 43.60070], + [-116.23875, 43.59995], + [-116.24052, 43.59990], + [-116.24055, 43.60060] + ]] + } + }, + + // 8. Zone G + { + type: "Feature", + properties: { priority: "low", applicationRate: 50 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24195, 43.59988], + [-116.24052, 43.59990], + [-116.24050, 43.59930], + [-116.24185, 43.59925], + [-116.24195, 43.59988] + ]] + } + }, + + // 9. Zone H + { + type: "Feature", + properties: { priority: "medium", applicationRate: 100 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24052, 43.59990], + [-116.23875, 43.59995], + [-116.23870, 43.59930], + [-116.24050, 43.59930], + [-116.24052, 43.59990] + ]] + } + }, + + // 10. Zone I + { + type: "Feature", + properties: { priority: "high", applicationRate: 170 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24185, 43.59925], + [-116.24050, 43.59930], + [-116.24048, 43.59870], + [-116.24180, 43.59865], + [-116.24185, 43.59925] + ]] + } + }, + + // 11. Zone J + { + type: "Feature", + properties: { priority: "low", applicationRate: 55 }, + geometry: { + type: "Polygon", + coordinates: [[ + [-116.24050, 43.59930], + [-116.23870, 43.59930], + [-116.23910, 43.59830], + [-116.24048, 43.59870], + [-116.24050, 43.59930] + ]] + } + } + ] +}; From a6400382c06fbe7f3a992b27be9852ab3ad82b48 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 7 Dec 2025 23:47:40 -0800 Subject: [PATCH 13/25] feat(input): add styling to manual coordinate upload modal --- .../src/components/InteractiveFarmMap.tsx | 48 ++-- .../src/components/ManualCoordinateUpload.tsx | 208 +++++++++++++++--- 2 files changed, 201 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/InteractiveFarmMap.tsx b/frontend/src/components/InteractiveFarmMap.tsx index 7173407..24c49de 100644 --- a/frontend/src/components/InteractiveFarmMap.tsx +++ b/frontend/src/components/InteractiveFarmMap.tsx @@ -1,9 +1,9 @@ -import React, { useState } from "react"; +import React from "react"; import { MapContainer, TileLayer, Marker, useMapEvent, Polygon } from "react-leaflet"; import { type LatLngLiteral } from "leaflet"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; -import { Box, Button } from "@mui/material"; +import { Box } from "@mui/material"; // temporary workaround for marker icon clash between Vite and React Leaflet delete L.Icon.Default.prototype._getIconUrl; @@ -28,32 +28,28 @@ const ClickHandler: React.FC = ({ markers, setMarkers }) => { return null; }; -export default function FarmMap() { - const [markers, setMarkers] = useState([]); - - const handleClearMarkers = () => { - setMarkers([]); - } +interface InteractiveFarmMapProps { + markers: LatLngLiteral[]; + setMarkers: React.Dispatch>; +} +export default function InteractiveFarmMap({ markers, setMarkers }: InteractiveFarmMapProps) { return ( - - - - - - - - - {markers.map((position, idx) => ( - - ))} - {markers.length >= 3 && } - - + + + + + + + {markers.map((position, idx) => ( + + ))} + {markers.length >= 3 && } + ); }; \ No newline at end of file diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/components/ManualCoordinateUpload.tsx index fb50fb5..306973f 100644 --- a/frontend/src/components/ManualCoordinateUpload.tsx +++ b/frontend/src/components/ManualCoordinateUpload.tsx @@ -1,38 +1,43 @@ -import { Box, Button, Dialog, DialogContent, DialogTitle, IconButton, Typography } from "@mui/material"; +import { Box, Button, Dialog, DialogContent, DialogTitle, IconButton, Typography, Divider, Paper, Stack } from "@mui/material"; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import { COLORS } from "../styles/colors"; import React from "react"; import InteractiveFarmMap from "./InteractiveFarmMap"; +import { type LatLngLiteral } from "leaflet"; export default function ManualCoordinateUpload() { const [isModalOpen, setIsModalOpen] = React.useState(false); + const [markers, setMarkers] = React.useState([]); const openModal = () => setIsModalOpen(true); - const closeModal = () => setIsModalOpen(false); + const closeModal = () => { + setIsModalOpen(false); + setMarkers([]); + }; - const handleSubmit = () => { - } + const handleClearMarkers = () => { + setMarkers([]); + }; - const FormContent = () => { - return ( - - Hello World. - - - - ) - } + const handleSubmit = () => { + console.log("Submitted coordinates:", markers); + // TODO: Implement submission logic + closeModal(); + }; return ( <> @@ -40,26 +45,171 @@ export default function ManualCoordinateUpload() { - - - - Back - + + + + + Back + + + Define Field Boundaries + + {/* Spacer for centering */} + - - + + + + + + {/* Map Section - Takes most of the space */} + + + + + {/* Sidebar - Instructions and Controls */} + + {/* Instructions Section */} + + + How to use: + + + + • Click on the map to place boundary markers for your field + + + • Place at least 3 markers to define a field boundary (polygon will appear automatically) + + + • Use Clear Markers to start over if needed + + + • Click Submit when you're satisfied with your field boundaries + + + + + {/* Status */} + + + + Markers placed: + + {markers.length >= 3 && ( + + Boundary defined + + )} + + + {markers.length} + + + + {/* Spacer to push buttons to bottom */} + + + {/* Action Buttons */} + + + + + + + From ffc16dffd15a780a5759e1c9292dd4b570e4cd57 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 14:45:05 -0700 Subject: [PATCH 14/25] feat: add MapLibre GL JS dependency --- frontend/package-lock.json | 278 ++++++++++++++++++++++++++++++++++++- frontend/package.json | 1 + 2 files changed, 278 insertions(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9973745..002e9a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "leaflet": "^1.9.4", + "maplibre-gl": "^5.15.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0-rc.2", @@ -756,6 +757,109 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz", + "integrity": "sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.2.tgz", + "integrity": "sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.0.tgz", + "integrity": "sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "geojson-vt": "^4.0.2", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.5.tgz", @@ -1393,9 +1497,17 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1463,6 +1575,15 @@ "@types/react": "*" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", @@ -2107,6 +2228,12 @@ "csstype": "^3.0.2" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.244", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", @@ -2487,6 +2614,30 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2712,6 +2863,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2725,6 +2882,12 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3067,6 +3230,44 @@ "yallist": "^3.0.2" } }, + "node_modules/maplibre-gl": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.15.0.tgz", + "integrity": "sha512-pPeu/t4yPDX/+Uf9ibLUdmaKbNMlGxMAX+tBednYukol2qNk2TZXAlhdohWxjVvTO3is8crrUYv3Ok02oAaKzA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.4.1", + "@maplibre/mlt": "^1.1.2", + "@maplibre/vt-pbf": "^4.2.0", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3104,12 +3305,27 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3267,6 +3483,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3315,6 +3543,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3342,6 +3576,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3373,6 +3613,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -3491,6 +3737,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -3567,6 +3822,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3650,6 +3911,15 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3723,6 +3993,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 73c6311..51f9a42 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", "leaflet": "^1.9.4", + "maplibre-gl": "^5.15.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0-rc.2", From ae523424b13fcfd331af61b673271e497f8f430c Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 14:45:35 -0700 Subject: [PATCH 15/25] feat: migrate from leafletJS to MapLibre GL JS --- .../src/components/InteractiveFarmMap.tsx | 56 +--- .../InteractiveFarmMap/InteractiveFarmMap.tsx | 57 ++++ .../src/components/PrescriptionMapViewer.tsx | 108 +------- .../PrescriptionMapViewer.tsx | 137 +++++++++ .../PrescriptionMapViewer/bounds.ts | 1 + .../PrescriptionMapViewer/priorityStyle.ts | 4 + frontend/src/samplePrescriptionData.ts | 259 +++++------------- frontend/src/types/maplibre/bounds.ts | 51 ++++ frontend/src/types/maplibre/priorityStyle.ts | 27 ++ 9 files changed, 354 insertions(+), 346 deletions(-) create mode 100644 frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx create mode 100644 frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx create mode 100644 frontend/src/components/PrescriptionMapViewer/bounds.ts create mode 100644 frontend/src/components/PrescriptionMapViewer/priorityStyle.ts create mode 100644 frontend/src/types/maplibre/bounds.ts create mode 100644 frontend/src/types/maplibre/priorityStyle.ts diff --git a/frontend/src/components/InteractiveFarmMap.tsx b/frontend/src/components/InteractiveFarmMap.tsx index 24c49de..0cdfd29 100644 --- a/frontend/src/components/InteractiveFarmMap.tsx +++ b/frontend/src/components/InteractiveFarmMap.tsx @@ -1,55 +1 @@ -import React from "react"; -import { MapContainer, TileLayer, Marker, useMapEvent, Polygon } from "react-leaflet"; -import { type LatLngLiteral } from "leaflet"; -import "leaflet/dist/leaflet.css"; -import L from "leaflet"; -import { Box } from "@mui/material"; - -// temporary workaround for marker icon clash between Vite and React Leaflet -delete L.Icon.Default.prototype._getIconUrl; -L.Icon.Default.mergeOptions({ - iconRetinaUrl: - "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png", - iconUrl: - "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png", - shadowUrl: - "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png", -}); - -interface ClickHandlerProps { - markers: LatLngLiteral[]; - setMarkers: React.Dispatch>; -} - -const ClickHandler: React.FC = ({ markers, setMarkers }) => { - useMapEvent("click", (e) => { - setMarkers([...markers, e.latlng]); - }); - return null; -}; - -interface InteractiveFarmMapProps { - markers: LatLngLiteral[]; - setMarkers: React.Dispatch>; -} - -export default function InteractiveFarmMap({ markers, setMarkers }: InteractiveFarmMapProps) { - return ( - - - - - - - {markers.map((position, idx) => ( - - ))} - {markers.length >= 3 && } - - - ); -}; \ No newline at end of file +export { default } from './InteractiveFarmMap/InteractiveFarmMap'; \ No newline at end of file diff --git a/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx b/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx new file mode 100644 index 0000000..b2758ad --- /dev/null +++ b/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { MapContainer, TileLayer, Marker, useMapEvent, Polygon } from "react-leaflet"; +import { type LatLngLiteral } from "leaflet"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; +import { Box } from "@mui/material"; + +// temporary workaround for marker icon clash between Vite and React Leaflet +// Cast to any to silence TS error on private property access. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png", + iconUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png", +}); + +interface ClickHandlerProps { + markers: LatLngLiteral[]; + setMarkers: React.Dispatch>; +} + +const ClickHandler: React.FC = ({ markers, setMarkers }) => { + useMapEvent("click", (e) => { + setMarkers([...markers, e.latlng]); + }); + return null; +}; + +interface InteractiveFarmMapProps { + markers: LatLngLiteral[]; + setMarkers: React.Dispatch>; +} + +export default function InteractiveFarmMap({ markers, setMarkers }: InteractiveFarmMapProps) { + return ( + + + + + + + {markers.map((position, idx) => ( + + ))} + {markers.length >= 3 && } + + + ); +} diff --git a/frontend/src/components/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer.tsx index bb1424c..60513ae 100644 --- a/frontend/src/components/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer.tsx @@ -1,107 +1 @@ -import { Box, Typography } from "@mui/material"; -import type { FeatureCollection } from 'geojson'; -import L from "leaflet"; -import type { LatLngExpression } from "leaflet"; -import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; -import 'leaflet/dist/leaflet.css'; -import { COLORS } from '../styles/colors'; -import React from "react"; -import { samplePrescriptionData } from "../samplePrescriptionData"; - -interface PrescriptionMapViewerProps { - /** A GeoJSON FeatureCollection from the backend. Expect the outer polygon (farm boundary) - * and inner polygons (application zones) as Features with properties like - * { applicationRate: number, priority: string } - */ - data?: FeatureCollection | null; - height?: number | string; - width?: number | string; -} - -const PRIORITY_COLORS: Record = { - "high": "#d7191c", - "medium-high": "#f58634", - "medium": "#f9d423", - "medium-low": "#a6d96a", - "low": "#1a9641", -}; - -function featureStyle(feature: any) { - const props = feature?.properties || {}; - const priority: string = (props.priority || props.priorityRange || '').toLowerCase(); - const rate = props.applicationRate ?? props.rate ?? null; - - const fillColor = PRIORITY_COLORS[priority] ?? (rate ? `rgba(100,100,255, ${Math.min(0.85, 0.25 + (rate / 200))})` : '#90caf9'); - - return { - color: '#222', - weight: 1, - opacity: 0.9, - fillColor, - fillOpacity: 0.6, - } as L.PathOptions; -} - -export default function PrescriptionMapViewer({ data = samplePrescriptionData, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { - // default center if no data - const defaultCenter: LatLngExpression = [44.5, -110]; - - // compute bounds from GeoJSON if provided - const bounds = data ? L.geoJSON(data).getBounds() : undefined; - - const onEachFeature = (feature: any, layer: L.Layer) => { - const props = feature?.properties || {}; - const applicationRate = props.applicationRate ?? props.rate ?? 'n/a'; - const priority = props.priority || props.priorityRange || 'n/a'; - - const html = `
Priority: ${priority}
Rate: ${applicationRate}
`; - if ((layer as any).bindPopup) { - (layer as any).bindPopup(html); - } - - // highlight on hover - layer.on('mouseover', () => { - (layer as any).setStyle && (layer as any).setStyle({ weight: 2.5, fillOpacity: 0.8 }); - }); - layer.on('mouseout', () => { - (layer as any).setStyle && (layer as any).setStyle(featureStyle(feature)); - }); - }; - - // helper component to fit map bounds when data changes - function FitBounds({ bounds }: { bounds?: L.LatLngBounds }) { - const map = useMap(); - React.useEffect(() => { - if (bounds && map) { - map.fitBounds(bounds.pad(0.05)); - } - }, [map, bounds]); - return null; - } - - return ( - - {!data && ( - - No prescription data available. Upload a GeoJSON from the backend to preview application zones. - - )} - - - - - - {data && ( - <> - featureStyle(feature)} onEachFeature={onEachFeature} /> - - - )} - - - - ); -} \ No newline at end of file +export { default } from './PrescriptionMapViewer/PrescriptionMapViewer'; \ No newline at end of file diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx new file mode 100644 index 0000000..aa8bbc5 --- /dev/null +++ b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx @@ -0,0 +1,137 @@ +import { Box, Typography } from "@mui/material"; +import type { FeatureCollection } from 'geojson'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { COLORS } from '../../styles/colors'; +import React from "react"; +import { samplePrescriptionData } from "../../samplePrescriptionData"; +import { computeBoundsFromGeoJSON } from '../../types/maplibre/bounds'; +import { priorityFillColorExpression } from '../../types/maplibre/priorityStyle'; + +interface PrescriptionMapViewerProps { + /** A GeoJSON FeatureCollection from the backend. Expect the outer polygon (farm boundary) + * and inner polygons (prescription zones) with properties such as + * { applicationRate: number, paybackPeriod: number } + */ + data?: FeatureCollection | null; + height?: number | string; + width?: number | string; +} + +export default function PrescriptionMapViewer({ data = samplePrescriptionData, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { + const mapContainerRef = React.useRef(null); + const mapRef = React.useRef(null); + const popupRef = React.useRef(null); + + React.useEffect(() => { + if (mapContainerRef.current && !mapRef.current) { + const map = new maplibregl.Map({ + container: mapContainerRef.current, + style: 'https://demotiles.maplibre.org/style.json', + center: [-110, 44.5], + zoom: 4, + }); + + mapRef.current = map; + + const sourceId = 'prescription-data'; + const fillLayerId = 'prescription-fill'; + const outlineLayerId = 'prescription-outline'; + + map.on('load', () => { + map.addSource(sourceId, { + type: 'geojson', + data: (data ?? samplePrescriptionData) as any, + }); + + map.addLayer({ + id: fillLayerId, + type: 'fill', + source: sourceId, + paint: { + 'fill-color': priorityFillColorExpression as any, + 'fill-opacity': 0.6, + }, + }); + + map.addLayer({ + id: outlineLayerId, + type: 'line', + source: sourceId, + paint: { + 'line-color': '#222', + 'line-width': 1.5, + 'line-opacity': 0.9, + }, + }); + + popupRef.current = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 }); + + map.on('mouseenter', fillLayerId, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mousemove', fillLayerId, (e) => { + const feature = e.features && e.features[0]; + const props: any = feature?.properties || {}; + const applicationRate = props.applicationRate ?? props.rate ?? 'n/a'; + const payback = props.paybackPeriod ?? 'n/a'; + const html = `
Payback Period: ${payback}
Application Rate: ${applicationRate}
`; + if (popupRef.current) { + popupRef.current.setLngLat(e.lngLat).setHTML(html).addTo(map); + } + }); + + map.on('mouseleave', fillLayerId, () => { + map.getCanvas().style.cursor = ''; + popupRef.current && popupRef.current.remove(); + }); + + if (data) { + const b = computeBoundsFromGeoJSON(data); + if (b) { + map.fitBounds(b, { padding: 20 }); + } + } + }); + } + + return () => { + if (popupRef.current) { + popupRef.current.remove(); + popupRef.current = null; + } + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, []); + + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + const source = map.getSource('prescription-data') as maplibregl.GeoJSONSource | undefined; + if (source && data) { + source.setData(data as any); + const b = computeBoundsFromGeoJSON(data); + if (b) { + map.fitBounds(b, { padding: 20 }); + } + } + }, [data]); + + return ( + + {!data && ( + + No prescription data available. Upload a GeoJSON from the backend to preview application zones. + + )} + + +
+ + + ); +} diff --git a/frontend/src/components/PrescriptionMapViewer/bounds.ts b/frontend/src/components/PrescriptionMapViewer/bounds.ts new file mode 100644 index 0000000..e495428 --- /dev/null +++ b/frontend/src/components/PrescriptionMapViewer/bounds.ts @@ -0,0 +1 @@ +export { computeBoundsFromGeoJSON } from '../../types/maplibre/bounds'; diff --git a/frontend/src/components/PrescriptionMapViewer/priorityStyle.ts b/frontend/src/components/PrescriptionMapViewer/priorityStyle.ts new file mode 100644 index 0000000..38e82ef --- /dev/null +++ b/frontend/src/components/PrescriptionMapViewer/priorityStyle.ts @@ -0,0 +1,4 @@ +// MapLibre GL paint expression to color polygons by priority. +// Supports `properties.priority` with fallback to `properties.priorityRange`. +// Fallback color is #90caf9. +export { priorityFillColorExpression } from '../../types/maplibre/priorityStyle'; diff --git a/frontend/src/samplePrescriptionData.ts b/frontend/src/samplePrescriptionData.ts index 7de2c5a..3e51ec5 100644 --- a/frontend/src/samplePrescriptionData.ts +++ b/frontend/src/samplePrescriptionData.ts @@ -1,191 +1,82 @@ -import type { FeatureCollection } from 'geojson'; +import type { FeatureCollection, Feature, Polygon } from 'geojson'; -export const samplePrescriptionData: FeatureCollection = { - type: "FeatureCollection", - features: [ - // ----------------------------------------------------- - // 1. Outer Farm Boundary (small, irregular polygon) - // ----------------------------------------------------- - { - type: "Feature", - properties: { type: "boundary" }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24210, 43.60220], - [-116.23890, 43.60225], - [-116.23840, 43.60010], - [-116.23910, 43.59830], - [-116.24180, 43.59820], - [-116.24240, 43.59960], - [-116.24210, 43.60220] - ]] - } - }, - - // ----------------------------------------------------- - // Inner polygons perfectly fill the boundary - // These are arranged like a puzzle so no gaps/overlaps - // ----------------------------------------------------- - - // 2. Zone A - { - type: "Feature", - properties: { priority: "high", applicationRate: 160 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24210, 43.60220], - [-116.24070, 43.60222], - [-116.24060, 43.60130], - [-116.24205, 43.60128], - [-116.24210, 43.60220] - ]] - } - }, - - // 3. Zone B - { - type: "Feature", - properties: { priority: "medium", applicationRate: 120 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24070, 43.60222], - [-116.23890, 43.60225], - [-116.23888, 43.60120], - [-116.24060, 43.60130], - [-116.24070, 43.60222] - ]] - } - }, - - // 4. Zone C - { - type: "Feature", - properties: { priority: "low", applicationRate: 70 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24205, 43.60128], - [-116.24060, 43.60130], - [-116.24055, 43.60060], - [-116.24200, 43.60055], - [-116.24205, 43.60128] - ]] - } - }, - - // 5. Zone D - { - type: "Feature", - properties: { priority: "medium", applicationRate: 95 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24060, 43.60130], - [-116.23888, 43.60120], - [-116.23880, 43.60070], - [-116.24055, 43.60060], - [-116.24060, 43.60130] - ]] - } - }, +// Point-in-polygon (ray casting) for simple polygon rings +function pointInPolygon(point: [number, number], ring: [number, number][]): boolean { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0], yi = ring[i][1]; + const xj = ring[j][0], yj = ring[j][1]; + const intersect = ((yi > point[1]) !== (yj > point[1])) && + (point[0] < (xj - xi) * (point[1] - yi) / (yj - yi + 0.0000001) + xi); + if (intersect) inside = !inside; + } + return inside; +} - // 6. Zone E - { - type: "Feature", - properties: { priority: "high", applicationRate: 180 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24200, 43.60055], - [-116.24055, 43.60060], - [-116.24052, 43.59990], - [-116.24195, 43.59988], - [-116.24200, 43.60055] - ]] - } - }, - - // 7. Zone F - { - type: "Feature", - properties: { priority: "medium-low", applicationRate: 85 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24055, 43.60060], - [-116.23880, 43.60070], - [-116.23875, 43.59995], - [-116.24052, 43.59990], - [-116.24055, 43.60060] - ]] - } - }, - - // 8. Zone G - { - type: "Feature", - properties: { priority: "low", applicationRate: 50 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24195, 43.59988], - [-116.24052, 43.59990], - [-116.24050, 43.59930], - [-116.24185, 43.59925], - [-116.24195, 43.59988] - ]] - } - }, +// Helper to build a square polygon given bottom-left corner and size in degrees +function square(lng: number, lat: number, sizeDeg = 0.0007): Feature> { + return { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[ + [lng, lat], + [lng + sizeDeg, lat], + [lng + sizeDeg, lat + sizeDeg], + [lng, lat + sizeDeg], + [lng, lat] + ]] + } + }; +} - // 9. Zone H - { - type: "Feature", - properties: { priority: "medium", applicationRate: 100 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24052, 43.59990], - [-116.23875, 43.59995], - [-116.23870, 43.59930], - [-116.24050, 43.59930], - [-116.24052, 43.59990] - ]] - } - }, +// Convex farm boundary (pentagon) near Boise, ID +const boundary: Feature = { + type: 'Feature', + properties: { type: 'boundary' }, + geometry: { + type: 'Polygon', + coordinates: [[ + [-116.2425, 43.6025], + [-116.2395, 43.6032], + [-116.2379, 43.6006], + [-116.2393, 43.5984], + [-116.2419, 43.5981], + [-116.2425, 43.6025] + ]] + } +}; - // 10. Zone I - { - type: "Feature", - properties: { priority: "high", applicationRate: 170 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24185, 43.59925], - [-116.24050, 43.59930], - [-116.24048, 43.59870], - [-116.24180, 43.59865], - [-116.24185, 43.59925] - ]] - } - }, +// Generate a small pixelated grid inside boundary bbox +const gridOrigin = { lng: -116.2418, lat: 43.5986 }; +const cellSize = 0.0007; // ~78m +const rows = 4; +const cols = 4; - // 11. Zone J - { - type: "Feature", - properties: { priority: "low", applicationRate: 55 }, - geometry: { - type: "Polygon", - coordinates: [[ - [-116.24050, 43.59930], - [-116.23870, 43.59930], - [-116.23910, 43.59830], - [-116.24048, 43.59870], - [-116.24050, 43.59930] - ]] - } +const grid: Feature>[] = []; +for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const lng = gridOrigin.lng + c * cellSize; + const lat = gridOrigin.lat + r * cellSize; + const f = square(lng, lat, cellSize); + // Uniform application rate for all squares; vary paybackPeriod numerically + const paybackOptions = [4, 8, 12, 18, 28]; // months + const idx = (r + c) % paybackOptions.length; + const center: [number, number] = [lng + cellSize / 2, lat + cellSize / 2]; + f.properties = { + applicationRate: 120, // same rate across field + paybackPeriod: paybackOptions[idx] + }; + // Include only squares whose center falls within the boundary (approximate clipping) + const ring = boundary.geometry.coordinates[0] as [number, number][]; + if (pointInPolygon(center, ring)) { + grid.push(f); } - ] + } +} + +export const samplePrescriptionData: FeatureCollection = { + type: 'FeatureCollection', + features: [boundary, ...grid] }; diff --git a/frontend/src/types/maplibre/bounds.ts b/frontend/src/types/maplibre/bounds.ts new file mode 100644 index 0000000..8be349c --- /dev/null +++ b/frontend/src/types/maplibre/bounds.ts @@ -0,0 +1,51 @@ +import type { FeatureCollection, Geometry } from 'geojson'; + +// Compute LngLatBoundsLike ([[minLng, minLat], [maxLng, maxLat]]) from a GeoJSON FeatureCollection +export function computeBoundsFromGeoJSON(data: FeatureCollection): [[number, number], [number, number]] | null { + let minLng = Infinity; + let minLat = Infinity; + let maxLng = -Infinity; + let maxLat = -Infinity; + + const expand = (coords: any): void => { + if (!coords) return; + if (typeof coords[0] === 'number' && typeof coords[1] === 'number') { + const lng = coords[0]; + const lat = coords[1]; + if (lng < minLng) minLng = lng; + if (lat < minLat) minLat = lat; + if (lng > maxLng) maxLng = lng; + if (lat > maxLat) maxLat = lat; + } else if (Array.isArray(coords)) { + for (const c of coords) expand(c); + } + }; + + for (const feature of data.features) { + const geom: Geometry | null = feature.geometry as any; + if (!geom) continue; + switch (geom.type) { + case 'Point': + case 'MultiPoint': + case 'LineString': + case 'MultiLineString': + case 'Polygon': + case 'MultiPolygon': + expand((geom as any).coordinates); + break; + case 'GeometryCollection': + for (const g of (geom as any).geometries || []) { + expand((g as any).coordinates); + } + break; + default: + break; + } + } + + if (minLng === Infinity || minLat === Infinity || maxLng === -Infinity || maxLat === -Infinity) { + return null; + } + + return [[minLng, minLat], [maxLng, maxLat]]; +} diff --git a/frontend/src/types/maplibre/priorityStyle.ts b/frontend/src/types/maplibre/priorityStyle.ts new file mode 100644 index 0000000..4919c0d --- /dev/null +++ b/frontend/src/types/maplibre/priorityStyle.ts @@ -0,0 +1,27 @@ +// MapLibre GL paint expression for fill-color. +// Primary: derive color by numeric ROI metric `paybackPeriod` (e.g., months). +// - Quickest payback -> red (urgent) +// - Slowest payback -> green (fine) +// Fallback: if `paybackPeriod` missing, use categorical priority/priorityRange mapping. +export const priorityFillColorExpression = [ + 'case', + ['has', 'paybackPeriod'], + // step(paybackPeriod, baseColor, stop1, color1, stop2, color2, ...) + ['step', ['get', 'paybackPeriod'], + '#d7191c', + 6, '#f58634', + 12, '#f9d423', + 18, '#a6d96a', + 24, '#1a9641' + ], + // Fallback to categorical priority mapping + ['match', + ['downcase', ['coalesce', ['to-string', ['get', 'priority']], ['to-string', ['get', 'priorityRange']], '' ]], + 'high', '#d7191c', + 'medium-high', '#f58634', + 'medium', '#f9d423', + 'medium-low', '#a6d96a', + 'low', '#1a9641', + '#90caf9' + ] +]; From 9c991a0805140b46e8f396f7e7e3b2c115f94d95 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 15:03:50 -0700 Subject: [PATCH 16/25] feat: create temporary state management system to integrate input and output --- .../src/components/CoordinateFileUpload.tsx | 25 ++++--- .../PrescriptionMapViewer.tsx | 56 ++++++++++++-- frontend/src/contexts/CoordinateContext.tsx | 74 +++++++++++++++++++ frontend/src/main.tsx | 7 +- frontend/src/services/coordinateService.ts | 25 +++++++ 5 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 frontend/src/contexts/CoordinateContext.tsx create mode 100644 frontend/src/services/coordinateService.ts diff --git a/frontend/src/components/CoordinateFileUpload.tsx b/frontend/src/components/CoordinateFileUpload.tsx index 892a584..ef7a558 100644 --- a/frontend/src/components/CoordinateFileUpload.tsx +++ b/frontend/src/components/CoordinateFileUpload.tsx @@ -3,6 +3,8 @@ import { styled } from "@mui/material/styles"; import React from "react"; import CloseIcon from "@mui/icons-material/Close"; import { uploadCoordinateFile } from "../services/fileUploadService"; +import { parseFileToGeoJSON } from "../services/coordinateService"; +import { useCoordinates } from "../contexts/CoordinateContext"; import { COLORS } from "../styles/colors"; const VisuallyHiddenInput = styled('input')({ @@ -19,6 +21,7 @@ const VisuallyHiddenInput = styled('input')({ export default function CoordinateFileUpload(props: { onSelect?: (file: File | null) => void; onUploadComplete?: () => void }) { const { onSelect, onUploadComplete } = props || {}; + const { setCoordinateData } = useCoordinates(); const [selectedFile, setSelectedFile] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); @@ -35,19 +38,17 @@ export default function CoordinateFileUpload(props: { onSelect?: (file: File | n if (!selectedFile) return; setIsLoading(true); - const formData = new FormData(); - formData.append('file', selectedFile); - try { - const response = await uploadCoordinateFile(formData); - if (response) { - console.log("Success! File uploaded"); - setSelectedFile(null); - onSelect?.(null); - onUploadComplete?.(); - } + // Parse file locally and save to context (temporary localStorage-based storage) + const geojson = await parseFileToGeoJSON(selectedFile); + setCoordinateData(geojson); + console.log("Success! Coordinates saved locally"); + setSelectedFile(null); + onSelect?.(null); + onUploadComplete?.(); } catch (err: any) { - console.error(err); + console.error('Failed to process coordinates:', err); + // TODO: Show error message in UI } finally { setIsLoading(false); } @@ -74,7 +75,7 @@ export default function CoordinateFileUpload(props: { onSelect?: (file: File | n Choose file diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx index aa8bbc5..0a7593a 100644 --- a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx @@ -1,12 +1,14 @@ -import { Box, Typography } from "@mui/material"; +import { Box, Typography, Button, Container } from "@mui/material"; import type { FeatureCollection } from 'geojson'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { COLORS } from '../../styles/colors'; import React from "react"; +import { useNavigate } from 'react-router'; import { samplePrescriptionData } from "../../samplePrescriptionData"; import { computeBoundsFromGeoJSON } from '../../types/maplibre/bounds'; import { priorityFillColorExpression } from '../../types/maplibre/priorityStyle'; +import { useCoordinates } from '../../contexts/CoordinateContext'; interface PrescriptionMapViewerProps { /** A GeoJSON FeatureCollection from the backend. Expect the outer polygon (farm boundary) @@ -19,6 +21,8 @@ interface PrescriptionMapViewerProps { } export default function PrescriptionMapViewer({ data = samplePrescriptionData, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { + const navigate = useNavigate(); + const { hasCoordinates, isLoading } = useCoordinates(); const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const popupRef = React.useRef(null); @@ -123,15 +127,51 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h return ( - {!data && ( - - No prescription data available. Upload a GeoJSON from the backend to preview application zones. - + {/* Guard: if no coordinates and not loading, prompt user to input */} + {!isLoading && !hasCoordinates && ( + + + + No Farm Coordinates Found + + + To view prescription maps, you need to first submit farm boundary coordinates. Please upload a coordinate file or manually define your farm area. + + + + )} - -
- + {/* Show map only when coordinates are available */} + {!isLoading && hasCoordinates && ( + <> + {!data && ( + + No prescription data available. Upload a GeoJSON from the backend to preview application zones. + + )} + + +
+ + + )} ); } diff --git a/frontend/src/contexts/CoordinateContext.tsx b/frontend/src/contexts/CoordinateContext.tsx new file mode 100644 index 0000000..67bff10 --- /dev/null +++ b/frontend/src/contexts/CoordinateContext.tsx @@ -0,0 +1,74 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import type { FeatureCollection } from 'geojson'; + +const STORAGE_KEY = 'charai_coordinate_data'; + +interface CoordinateContextType { + data: FeatureCollection | null; + isLoading: boolean; + hasCoordinates: boolean; + setCoordinateData: (data: FeatureCollection) => void; + clearCoordinateData: () => void; +} + +const CoordinateContext = createContext(undefined); + +export function CoordinateProvider({ children }: { children: ReactNode }) { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // On mount, load coordinate data from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + setData(JSON.parse(stored)); + } + } catch (err) { + console.debug('Failed to load coordinates from localStorage:', err); + } finally { + setIsLoading(false); + } + }, []); + + const setCoordinateData = (newData: FeatureCollection) => { + setData(newData); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(newData)); + } catch (err) { + console.error('Failed to save coordinates to localStorage:', err); + } + }; + + const clearCoordinateData = () => { + setData(null); + try { + localStorage.removeItem(STORAGE_KEY); + } catch (err) { + console.error('Failed to clear coordinates from localStorage:', err); + } + }; + + const value: CoordinateContextType = { + data, + isLoading, + hasCoordinates: !!data, + setCoordinateData, + clearCoordinateData, + }; + + return ( + + {children} + + ); +} + +export function useCoordinates() { + const context = useContext(CoordinateContext); + if (!context) { + throw new Error('useCoordinates must be used within a CoordinateProvider'); + } + return context; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ec8d40d..0a1f35e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,13 +5,16 @@ import './index.css' import Header from './components/Header.tsx' import AppRoutes from './components/AppRoutes.tsx' import { AuthProvider } from './contexts/AuthContext' +import { CoordinateProvider } from './contexts/CoordinateContext' createRoot(document.getElementById('root')!).render( -
- + +
+ + , diff --git a/frontend/src/services/coordinateService.ts b/frontend/src/services/coordinateService.ts new file mode 100644 index 0000000..9cff652 --- /dev/null +++ b/frontend/src/services/coordinateService.ts @@ -0,0 +1,25 @@ +// Temporary localStorage-based coordinate service +// TODO: Replace with backend API calls when ready + +import type { FeatureCollection } from 'geojson'; + +// Placeholder function for file parsing +// In the future, backend will handle file upload and return parsed GeoJSON +export function parseFileToGeoJSON(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target?.result as string; + // For now, assume file is valid GeoJSON + // TODO: Implement proper file parsing or delegate to backend + const geojson = JSON.parse(content) as FeatureCollection; + resolve(geojson); + } catch (err) { + reject(new Error('Failed to parse file as GeoJSON')); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); +} From 43e53f4287a35533f974574b727fee8a8a323358 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 15:11:29 -0700 Subject: [PATCH 17/25] feat: add manual coordinates as global state variable --- frontend/src/components/FarmBiocharForm.tsx | 4 +- .../src/components/ManualCoordinateUpload.tsx | 58 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/FarmBiocharForm.tsx b/frontend/src/components/FarmBiocharForm.tsx index 890e7bc..1f4fcde 100644 --- a/frontend/src/components/FarmBiocharForm.tsx +++ b/frontend/src/components/FarmBiocharForm.tsx @@ -15,6 +15,7 @@ import FieldsList from './FieldsList'; import type { FieldEntry } from './FieldsList'; import FileUploadSection from './FileUploadSection'; import SubmitSection from './SubmitSection'; +import { useCoordinates } from '../contexts/CoordinateContext'; const DEFAULT_FIELD = (): FieldEntry => ({ id: String(Date.now()) + Math.random().toString(36).slice(2, 9), @@ -25,6 +26,7 @@ const DEFAULT_FIELD = (): FieldEntry => ({ }); export default function FarmBiocharForm() { + const { hasCoordinates } = useCoordinates(); const [fields, setFields] = React.useState([DEFAULT_FIELD()]); const [globalMax, setGlobalMax] = React.useState(''); const [isModalOpen, setIsModalOpen] = React.useState(false); @@ -114,7 +116,7 @@ export default function FarmBiocharForm() { onClick={openModal} sx={{ backgroundColor: COLORS.indigo, '&:hover': { backgroundColor: '#7a81ff' } }} > - Configure Farm + {hasCoordinates ? 'Edit Farm Configuration' : 'Configure Farm'} {/* Modal Dialog */} diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/components/ManualCoordinateUpload.tsx index 306973f..a3fe8a0 100644 --- a/frontend/src/components/ManualCoordinateUpload.tsx +++ b/frontend/src/components/ManualCoordinateUpload.tsx @@ -2,19 +2,40 @@ import { Box, Button, Dialog, DialogContent, DialogTitle, IconButton, Typography import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import EditIcon from '@mui/icons-material/Edit'; import { COLORS } from "../styles/colors"; import React from "react"; import InteractiveFarmMap from "./InteractiveFarmMap"; import { type LatLngLiteral } from "leaflet"; +import { useCoordinates } from "../contexts/CoordinateContext"; +import type { FeatureCollection, Feature, Polygon } from 'geojson'; export default function ManualCoordinateUpload() { + const { data, setCoordinateData } = useCoordinates(); const [isModalOpen, setIsModalOpen] = React.useState(false); const [markers, setMarkers] = React.useState([]); + // On mount or when modal opens with existing data, load markers from context + React.useEffect(() => { + if (isModalOpen && data) { + // Extract markers from the boundary polygon (first feature with type Polygon) + const boundaryFeature = data.features.find(f => f.geometry.type === 'Polygon') as Feature | undefined; + if (boundaryFeature?.geometry.type === 'Polygon') { + const coords = boundaryFeature.geometry.coordinates[0]; + // Remove last coord (which closes the polygon) + const polygonMarkers: LatLngLiteral[] = coords.slice(0, -1).map(([lng, lat]) => ({ + lat, + lng, + })); + setMarkers(polygonMarkers); + } + } + }, [isModalOpen, data]); + const openModal = () => setIsModalOpen(true); const closeModal = () => { setIsModalOpen(false); - setMarkers([]); + // Don't clear markers on close—they persist until re-submitted }; const handleClearMarkers = () => { @@ -22,15 +43,42 @@ export default function ManualCoordinateUpload() { }; const handleSubmit = () => { - console.log("Submitted coordinates:", markers); - // TODO: Implement submission logic + if (markers.length < 3) return; + + // Convert markers to GeoJSON boundary polygon + const coords: [number, number][] = markers.map(m => [m.lng, m.lat]); + // Close the polygon by adding the first point at the end + coords.push(coords[0]); + + const boundary: Feature = { + type: 'Feature', + properties: { type: 'boundary' }, + geometry: { + type: 'Polygon', + coordinates: [coords], + }, + }; + + const geojson: FeatureCollection = { + type: 'FeatureCollection', + features: [boundary], + }; + + // Save to context (which persists to localStorage) + setCoordinateData(geojson); + + console.log('Submitted coordinates:', markers); closeModal(); }; - + + // Check if coordinates have already been submitted + const hasSubmittedCoordinates = data && data.features.length > 0; + return ( <> Date: Fri, 26 Dec 2025 15:15:16 -0700 Subject: [PATCH 18/25] feat: connect manual coordinates and output validation to state management --- frontend/src/components/FarmBiocharForm.tsx | 13 +++++++++++-- frontend/src/components/FileUploadSection.tsx | 4 ++-- frontend/src/components/ManualCoordinateUpload.tsx | 7 ++++++- frontend/src/components/SubmitSection.tsx | 14 +++++++------- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/FarmBiocharForm.tsx b/frontend/src/components/FarmBiocharForm.tsx index 1f4fcde..627dd15 100644 --- a/frontend/src/components/FarmBiocharForm.tsx +++ b/frontend/src/components/FarmBiocharForm.tsx @@ -39,9 +39,16 @@ export default function FarmBiocharForm() { setFields(prev => prev.map(f => (f.id === id ? { ...f, ...patch } : f))); }; - // file upload state + // file/manual coordinate state (ready when uploaded OR drawn OR already present) const [coordUploaded, setCoordUploaded] = React.useState(false); + // If coordinates already exist in context (from upload or manual draw), mark as ready + React.useEffect(() => { + if (hasCoordinates) { + setCoordUploaded(true); + } + }, [hasCoordinates]); + const handleCoordSelect = (file: File | null) => { // If a file is selected, mark coordinates as uploaded/available so the form can be submitted. // This covers the common case where the user selects/uploads a file and we want the @@ -65,6 +72,8 @@ export default function FarmBiocharForm() { const openModal = () => setIsModalOpen(true); const closeModal = () => setIsModalOpen(false); + const coordsReady = coordUploaded || hasCoordinates; + const FormContent = () => ( {/* Title Section */} @@ -98,7 +107,7 @@ export default function FarmBiocharForm() { {/* Submit Section */} { const payload = { globalMax, fields }; console.log('Submit payload', payload); diff --git a/frontend/src/components/FileUploadSection.tsx b/frontend/src/components/FileUploadSection.tsx index 3a848e4..8beeb8d 100644 --- a/frontend/src/components/FileUploadSection.tsx +++ b/frontend/src/components/FileUploadSection.tsx @@ -36,13 +36,13 @@ export default function FileUploadSection({ - Upload a file containing your farm boundary coordinates. This is essential for accurate spatial analysis and field mapping. + Upload a file containing your farm boundary coordinates, or draw them manually. Either method will enable submission. Accepted formats: Shapefile (.shp, .shx, .dbf), GeoJSON (.geojson), CSV (.csv), KML/KMZ (.kml, .kmz) - + {/* Yield File Box */} diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/components/ManualCoordinateUpload.tsx index a3fe8a0..ba59e0b 100644 --- a/frontend/src/components/ManualCoordinateUpload.tsx +++ b/frontend/src/components/ManualCoordinateUpload.tsx @@ -10,7 +10,11 @@ import { type LatLngLiteral } from "leaflet"; import { useCoordinates } from "../contexts/CoordinateContext"; import type { FeatureCollection, Feature, Polygon } from 'geojson'; -export default function ManualCoordinateUpload() { +type ManualCoordinateUploadProps = { + onSubmitted?: () => void; +}; + +export default function ManualCoordinateUpload({ onSubmitted }: ManualCoordinateUploadProps) { const { data, setCoordinateData } = useCoordinates(); const [isModalOpen, setIsModalOpen] = React.useState(false); const [markers, setMarkers] = React.useState([]); @@ -66,6 +70,7 @@ export default function ManualCoordinateUpload() { // Save to context (which persists to localStorage) setCoordinateData(geojson); + onSubmitted?.(); console.log('Submitted coordinates:', markers); closeModal(); diff --git a/frontend/src/components/SubmitSection.tsx b/frontend/src/components/SubmitSection.tsx index 902f9ed..83cc3ae 100644 --- a/frontend/src/components/SubmitSection.tsx +++ b/frontend/src/components/SubmitSection.tsx @@ -2,11 +2,11 @@ import { Box, Button, Stack, Typography } from '@mui/material'; import { COLORS } from '../styles/colors'; interface SubmitSectionProps { - coordUploaded: boolean; + coordsReady: boolean; onSubmit: () => void; } -export default function SubmitSection({ coordUploaded, onSubmit }: SubmitSectionProps) { +export default function SubmitSection({ coordsReady, onSubmit }: SubmitSectionProps) { return ( @@ -16,7 +16,7 @@ export default function SubmitSection({ coordUploaded, onSubmit }: SubmitSection - {!coordUploaded && ( + {!coordsReady && ( - Upload coordinate file to enable submission + Upload or draw your boundary to enable submission )} - {coordUploaded && ( + {coordsReady && ( - Ready to submit + Boundary received — ready to submit )} From 721eb36b977d2c1e10cd631954ca4545a16fc9d1 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 15:26:27 -0700 Subject: [PATCH 19/25] fix: adjust global state context logic --- frontend/src/components/FarmBiocharForm.tsx | 11 ++-- frontend/src/components/FileUploadSection.tsx | 4 +- .../src/components/ManualCoordinateUpload.tsx | 7 +-- .../PrescriptionMapViewer.tsx | 27 +++++++-- frontend/src/contexts/CoordinateContext.tsx | 59 +++++++++++++++++-- 5 files changed, 85 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/FarmBiocharForm.tsx b/frontend/src/components/FarmBiocharForm.tsx index 627dd15..14108ad 100644 --- a/frontend/src/components/FarmBiocharForm.tsx +++ b/frontend/src/components/FarmBiocharForm.tsx @@ -26,7 +26,7 @@ const DEFAULT_FIELD = (): FieldEntry => ({ }); export default function FarmBiocharForm() { - const { hasCoordinates } = useCoordinates(); + const { hasCoordinates, hasPendingCoordinates, formSubmitted, setFormSubmitted, commitPendingCoordinates } = useCoordinates(); const [fields, setFields] = React.useState([DEFAULT_FIELD()]); const [globalMax, setGlobalMax] = React.useState(''); const [isModalOpen, setIsModalOpen] = React.useState(false); @@ -44,10 +44,10 @@ export default function FarmBiocharForm() { // If coordinates already exist in context (from upload or manual draw), mark as ready React.useEffect(() => { - if (hasCoordinates) { + if (hasCoordinates || hasPendingCoordinates) { setCoordUploaded(true); } - }, [hasCoordinates]); + }, [hasCoordinates, hasPendingCoordinates]); const handleCoordSelect = (file: File | null) => { // If a file is selected, mark coordinates as uploaded/available so the form can be submitted. @@ -72,7 +72,7 @@ export default function FarmBiocharForm() { const openModal = () => setIsModalOpen(true); const closeModal = () => setIsModalOpen(false); - const coordsReady = coordUploaded || hasCoordinates; + const coordsReady = coordUploaded || hasPendingCoordinates || hasCoordinates; const FormContent = () => ( @@ -112,6 +112,9 @@ export default function FarmBiocharForm() { const payload = { globalMax, fields }; console.log('Submit payload', payload); alert('Form submitted! Check console for payload details.'); + commitPendingCoordinates(); + setFormSubmitted(true); + closeModal(); }} /> diff --git a/frontend/src/components/FileUploadSection.tsx b/frontend/src/components/FileUploadSection.tsx index 8beeb8d..12ae246 100644 --- a/frontend/src/components/FileUploadSection.tsx +++ b/frontend/src/components/FileUploadSection.tsx @@ -36,13 +36,13 @@ export default function FileUploadSection({ - Upload a file containing your farm boundary coordinates, or draw them manually. Either method will enable submission. + Upload a file containing your farm boundary coordinates, or draw them manually. Accepted formats: Shapefile (.shp, .shx, .dbf), GeoJSON (.geojson), CSV (.csv), KML/KMZ (.kml, .kmz) - + {/* Yield File Box */} diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/components/ManualCoordinateUpload.tsx index ba59e0b..a3fe8a0 100644 --- a/frontend/src/components/ManualCoordinateUpload.tsx +++ b/frontend/src/components/ManualCoordinateUpload.tsx @@ -10,11 +10,7 @@ import { type LatLngLiteral } from "leaflet"; import { useCoordinates } from "../contexts/CoordinateContext"; import type { FeatureCollection, Feature, Polygon } from 'geojson'; -type ManualCoordinateUploadProps = { - onSubmitted?: () => void; -}; - -export default function ManualCoordinateUpload({ onSubmitted }: ManualCoordinateUploadProps) { +export default function ManualCoordinateUpload() { const { data, setCoordinateData } = useCoordinates(); const [isModalOpen, setIsModalOpen] = React.useState(false); const [markers, setMarkers] = React.useState([]); @@ -70,7 +66,6 @@ export default function ManualCoordinateUpload({ onSubmitted }: ManualCoordinate // Save to context (which persists to localStorage) setCoordinateData(geojson); - onSubmitted?.(); console.log('Submitted coordinates:', markers); closeModal(); diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx index 0a7593a..a427be9 100644 --- a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx @@ -22,7 +22,7 @@ interface PrescriptionMapViewerProps { export default function PrescriptionMapViewer({ data = samplePrescriptionData, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { const navigate = useNavigate(); - const { hasCoordinates, isLoading } = useCoordinates(); + const { hasCoordinates, isLoading, clearCoordinateData, formSubmitted, setFormSubmitted } = useCoordinates(); const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const popupRef = React.useRef(null); @@ -127,8 +127,8 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h return ( - {/* Guard: if no coordinates and not loading, prompt user to input */} - {!isLoading && !hasCoordinates && ( + {/* Guard: require committed coords AND submitted form */} + {!isLoading && (!hasCoordinates || !formSubmitted) && ( - To view prescription maps, you need to first submit farm boundary coordinates. Please upload a coordinate file or manually define your farm area. + To view prescription maps, please submit your farm configuration with boundary coordinates. + + {!data && ( No prescription data available. Upload a GeoJSON from the backend to preview application zones. diff --git a/frontend/src/contexts/CoordinateContext.tsx b/frontend/src/contexts/CoordinateContext.tsx index 67bff10..bcd8de7 100644 --- a/frontend/src/contexts/CoordinateContext.tsx +++ b/frontend/src/contexts/CoordinateContext.tsx @@ -2,13 +2,20 @@ import { createContext, useContext, useState, useEffect } from 'react'; import type { ReactNode } from 'react'; import type { FeatureCollection } from 'geojson'; -const STORAGE_KEY = 'charai_coordinate_data'; +const STORAGE_KEY = 'charai_coordinate_data'; // committed/approved coordinates +const PENDING_STORAGE_KEY = 'charai_coordinate_pending'; // latest user input (manual or upload) before submit +const SUBMIT_STORAGE_KEY = 'charai_farm_submitted'; interface CoordinateContextType { data: FeatureCollection | null; + pendingData: FeatureCollection | null; isLoading: boolean; hasCoordinates: boolean; - setCoordinateData: (data: FeatureCollection) => void; + hasPendingCoordinates: boolean; + formSubmitted: boolean; + setCoordinateData: (data: FeatureCollection) => void; // sets pending draft coords + commitPendingCoordinates: () => void; // promote pending -> committed and mark submitted + setFormSubmitted: (submitted: boolean) => void; clearCoordinateData: () => void; } @@ -16,7 +23,9 @@ const CoordinateContext = createContext(undef export function CoordinateProvider({ children }: { children: ReactNode }) { const [data, setData] = useState(null); + const [pendingData, setPendingData] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [formSubmitted, setFormSubmittedState] = useState(false); // On mount, load coordinate data from localStorage useEffect(() => { @@ -25,6 +34,16 @@ export function CoordinateProvider({ children }: { children: ReactNode }) { if (stored) { setData(JSON.parse(stored)); } + + const storedPending = localStorage.getItem(PENDING_STORAGE_KEY); + if (storedPending) { + setPendingData(JSON.parse(storedPending)); + } + + const storedSubmitted = localStorage.getItem(SUBMIT_STORAGE_KEY); + if (storedSubmitted) { + setFormSubmittedState(storedSubmitted === 'true'); + } } catch (err) { console.debug('Failed to load coordinates from localStorage:', err); } finally { @@ -33,18 +52,43 @@ export function CoordinateProvider({ children }: { children: ReactNode }) { }, []); const setCoordinateData = (newData: FeatureCollection) => { - setData(newData); + setPendingData(newData); + try { + localStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(newData)); + } catch (err) { + console.error('Failed to save pending coordinates to localStorage:', err); + } + }; + + const commitPendingCoordinates = () => { + if (!pendingData) return; + setData(pendingData); + setFormSubmittedState(true); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(pendingData)); + localStorage.setItem(SUBMIT_STORAGE_KEY, 'true'); + } catch (err) { + console.error('Failed to commit coordinates to localStorage:', err); + } + }; + + const setFormSubmitted = (submitted: boolean) => { + setFormSubmittedState(submitted); try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(newData)); + localStorage.setItem(SUBMIT_STORAGE_KEY, submitted ? 'true' : 'false'); } catch (err) { - console.error('Failed to save coordinates to localStorage:', err); + console.error('Failed to save submission flag to localStorage:', err); } }; const clearCoordinateData = () => { setData(null); + setPendingData(null); + setFormSubmittedState(false); try { localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(PENDING_STORAGE_KEY); + localStorage.setItem(SUBMIT_STORAGE_KEY, 'false'); } catch (err) { console.error('Failed to clear coordinates from localStorage:', err); } @@ -52,9 +96,14 @@ export function CoordinateProvider({ children }: { children: ReactNode }) { const value: CoordinateContextType = { data, + pendingData, isLoading, hasCoordinates: !!data, + hasPendingCoordinates: !!pendingData, + formSubmitted, setCoordinateData, + commitPendingCoordinates, + setFormSubmitted, clearCoordinateData, }; From 7a84932c20bffa662ee17eeda330b7de93c6d281 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 16:16:53 -0700 Subject: [PATCH 20/25] feat: add turf dependency for easy map handling --- frontend/package-lock.json | 2245 +++++++++++++++++++++++++++++++++++- frontend/package.json | 1 + 2 files changed, 2242 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 002e9a7..bbc72be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", + "@turf/turf": "^7.3.1", "leaflet": "^1.9.4", "maplibre-gl": "^5.15.0", "react": "^19.2.0", @@ -1430,6 +1431,2038 @@ "dev": true, "license": "MIT" }, + "node_modules/@turf/along": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/along/-/along-7.3.1.tgz", + "integrity": "sha512-z84b9PKsUB69BhkeHA6oPqRO7VaJHwTid1SpuIbwWzDqHTpq8buJBKlrKgHIIthuVr5P/AZiEXmf3R4ifRhDmw==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.3.1", + "@turf/destination": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/angle": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/angle/-/angle-7.3.1.tgz", + "integrity": "sha512-Pcb0Fg8WHsOMKFvIPaYfORrlLYdytWjVAkVTnAqJdmGI+2n+eLROPjJO2sJbpX9yU/dlBgujOB7a1d0PJjhHyQ==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/rhumb-bearing": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/area": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.1.tgz", + "integrity": "sha512-9nSiwt4zB5QDMcSoTxF28WpK1f741MNKcpUJDiHVRX08CZ4qfGWGV9ZIPQ8TVEn5RE4LyYkFuQ47Z9pdEUZE9Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.1.tgz", + "integrity": "sha512-/IyMKoS7P9B0ch5PIlQ6gMfoE8gRr48+cSbzlyexvEjuDuaAV1VURjH1jAthS0ipFG8RrFxFJKnp7TLL1Skong==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox-clip": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/bbox-clip/-/bbox-clip-7.3.1.tgz", + "integrity": "sha512-YUeITFtp5QLbpSS0XyQa0GlgMqK4PMgjOeOGOTlWsfDYaqc5SErf7o5UyCOsLAPQW16QZVxJ26uTAE20YkluAA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox-polygon": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-7.3.1.tgz", + "integrity": "sha512-2NvwPfuRtwJk7w5HIC/Knei3mUXrVT+t/0FB1zStgDbakmXrqKISaftlIh4YTOVlUsVnvq0tggjFMLZ/Xxo+lQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bearing": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-7.3.1.tgz", + "integrity": "sha512-ex78l/LiY6uO6jO8AJepyWE6/tiWEbXjKLOgqUfJSkW23UcMVlhbAKzXDjbsdz9T66sXFC/6QNAh8oaZzmoo6w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bezier-spline": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/bezier-spline/-/bezier-spline-7.3.1.tgz", + "integrity": "sha512-7Mal/d8ttTQ5eu/mwgC53iH9eYBRTBHXsIqEEiTVHChh1iajNuS4/bwYdaxsQsRXKVaFfx+4dCy0cRmqhjgTrw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-clockwise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-7.3.1.tgz", + "integrity": "sha512-ik9j0CCrsp/JZ42tbCnyZg86YFoavEU/nyal3HsEgdY5WFYq43aMYqLPRi6yNqE48THEk3fl1BcfgJqAiUhDFA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-concave": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-concave/-/boolean-concave-7.3.1.tgz", + "integrity": "sha512-jAAt5MhqXSKmRmX7l09oeo9dObf7bMDuzfeUSSNAK+yAi9TE5QWlP4JtzOWC5+gKXsL8dvzE8mvsQj38FzQdEA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-contains": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-contains/-/boolean-contains-7.3.1.tgz", + "integrity": "sha512-VvytV9ZcUgnitzm5ILVWIoOhoZOh8VZ4dnweUJM3N+A77CzXXFk8e4NqPNZ6tZVPY3ehxzDXrq1+iN87pMcB7g==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/boolean-point-on-line": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-crosses": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-crosses/-/boolean-crosses-7.3.1.tgz", + "integrity": "sha512-Fn99AxTXQORiQjclUqUYQcA40oJJoJxMBFx/Vycd7v949Lnplt1qrUkBpbZNXQlvHF2gxrgirSfgBDaUnUJjzQ==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/line-intersect": "7.3.1", + "@turf/polygon-to-line": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-disjoint": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-disjoint/-/boolean-disjoint-7.3.1.tgz", + "integrity": "sha512-bqVo+eAYaCq0lcr09zsZdWIAdv22UzGc/h2CCfaBwP5r4o/rFudNFLU9gb9BcM6dBUzrtTgBguShAZr7k3cGbw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/line-intersect": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/polygon-to-line": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-equal": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-equal/-/boolean-equal-7.3.1.tgz", + "integrity": "sha512-nEsmmNdwD1nzYZLsO6hPC/X/Uag+eT0yuWamD0XxJAQhXBsnSATxKisCJXVJgXvO8M0qvEMW1zZrUGB6Fjfzzw==", + "license": "MIT", + "dependencies": { + "@turf/clean-coords": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "geojson-equality-ts": "^1.0.2", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-intersects": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-intersects/-/boolean-intersects-7.3.1.tgz", + "integrity": "sha512-nc6W8qFdzFkfsR6p506HINGu85nHk/Skm+cw3TRQZ5/A44hjf0kYnbhvS3qrCAws3bR+/FKK8O1bsO/Udk8kkg==", + "license": "MIT", + "dependencies": { + "@turf/boolean-disjoint": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-overlap": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-overlap/-/boolean-overlap-7.3.1.tgz", + "integrity": "sha512-QhhsgCLzkwXIeZhaCmgE3H8yTANJGZatJ5IzQG3xnPTx7LiNAaa/ReN2/NroEv++8Yc0sr5Bkh6xWZOtew1dvQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/line-intersect": "7.3.1", + "@turf/line-overlap": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "geojson-equality-ts": "^1.0.2", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-parallel": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-parallel/-/boolean-parallel-7.3.1.tgz", + "integrity": "sha512-SXPyYiuaRB1ES/LtcUP11HWyloMJGzN1nYaCLG7H+6l2OKjVJl025qR6uxVElWCzAdElek9nGNeNya1hd9ZHaw==", + "license": "MIT", + "dependencies": { + "@turf/clean-coords": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/line-segment": "7.3.1", + "@turf/rhumb-bearing": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.1.tgz", + "integrity": "sha512-BUPW63vE43LctwkgannjmEFTX1KFR/18SS7WzFahJWK1ZoP0s1jrfxGX+pi0BH/3Dd9mA71hkGKDDnj1Ndcz0g==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-point-on-line": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-on-line/-/boolean-point-on-line-7.3.1.tgz", + "integrity": "sha512-8Hywuv7XFpSc8nfH0BJBtt+XTcJ7OjfjpX2Sz+ty8gyiY/2nCLLqq6amu3ebr67ruqZTDpPNQoGGUbUePjF3rA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-touches": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-touches/-/boolean-touches-7.3.1.tgz", + "integrity": "sha512-XqrQzYGTakoTWeTWT274pfObpbIpAM7L8CzGUa04rJD0l3bv3VK4TUw0v6+bywi5ea6TnJzvOzgvzTb1DtvBKA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/boolean-point-on-line": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-valid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-valid/-/boolean-valid-7.3.1.tgz", + "integrity": "sha512-lpw4J5HaV4Tv033s2j/i6QHt6Zx/8Lc90DTfOU0axgRSrs127kbKNJsmDEGvtmV7YjNp8aPbIG1wwAX9wg/dMA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/boolean-crosses": "7.3.1", + "@turf/boolean-disjoint": "7.3.1", + "@turf/boolean-overlap": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/boolean-point-on-line": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/line-intersect": "7.3.1", + "@types/geojson": "^7946.0.10", + "geojson-polygon-self-intersections": "^1.2.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/boolean-within": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/boolean-within/-/boolean-within-7.3.1.tgz", + "integrity": "sha512-oxP4VU81RRCf59TXCBhVWEyJ5Lsr+wrqvqSAFxyBuur5oLmBqZdYyvL7FQJmYvG0uOxX7ohyHmSJMaTe4EhGDA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/boolean-point-on-line": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/buffer": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/buffer/-/buffer-7.3.1.tgz", + "integrity": "sha512-jtdI0Ir3GwPyY1V2dFX039HNhD8MIYLX39c7b9AZdLh7kBuD2VgXJmPvhtnivqMV2SmRlS4fd9cKzNj369/cGg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/center": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/jsts": "^2.7.1", + "@turf/meta": "7.3.1", + "@turf/projection": "7.3.1", + "@types/geojson": "^7946.0.10", + "d3-geo": "1.7.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/center/-/center-7.3.1.tgz", + "integrity": "sha512-czqNKLGGdik3phYsWCK5SHKBRkDulUArMlG4v62IQcNcRFq9MbOGqyN21GSshSMO792ynDeWzdXdcKmycQ14Yg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center-mean": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/center-mean/-/center-mean-7.3.1.tgz", + "integrity": "sha512-koVenhCl8JPEvtDwH6nhZpLAm9+7XOXosqKdkXyK1uDae3NRyoQQeIYD7nIJHJPCOyeacw6buWzAEoAleBj0XA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center-median": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/center-median/-/center-median-7.3.1.tgz", + "integrity": "sha512-XIvxqnSdcUFOev4WO8AEQth4U3uzfQkxYVkKhZrxpVitqEeSDm5v3ANUeVGYqQ/QNTWvFAFn4zB5+XRRd8tayA==", + "license": "MIT", + "dependencies": { + "@turf/center-mean": "7.3.1", + "@turf/centroid": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/center-of-mass": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/center-of-mass/-/center-of-mass-7.3.1.tgz", + "integrity": "sha512-w2O7RLc0tSs+eEsZCaWa1lYiACsaQTJtie/a4bj5ta1TDTAEjyxC6Rp6br4mN1XPzeSFbEuNw+q9/VdSXU/mGA==", + "license": "MIT", + "dependencies": { + "@turf/centroid": "7.3.1", + "@turf/convex": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.1.tgz", + "integrity": "sha512-hRnsDdVBH4pX9mAjYympb2q5W8TCMUMNEjcRrAF7HTCyjIuRmjJf8vUtlzf7TTn9RXbsvPc1vtm3kLw20Jm8DQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/circle": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/circle/-/circle-7.3.1.tgz", + "integrity": "sha512-UY2OM1OK7IuyrtN3YE8026ZM3xM9VIkqZ0vRZln8g33D0AogrJVJ/I9T81/VpRPlxTnrbDpzQxJQBH+3vPG/Ow==", + "license": "MIT", + "dependencies": { + "@turf/destination": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clean-coords": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-7.3.1.tgz", + "integrity": "sha512-uNo4lnTekvkw8dUCXIVCc38nZiHBrpy5jn0T8hlodZo/A4XAChFtLQi8NLcX8rtXcaNxeJo+yaPfpP3PSVI2jw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-on-line": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clone": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-7.3.1.tgz", + "integrity": "sha512-r7xDOfw9ohA7PhZW+8X9RMsO4szB4YqkhEROaELJyLtQ1bo8VNFtndpZdE6YHQpD7Pjlvlb6i99q8w1QLisEPg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clusters": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/clusters/-/clusters-7.3.1.tgz", + "integrity": "sha512-ZELehyYnsozw+AHOc426abmPaGJOt46BHnCN+hwtPOkqEbvdZYu+16Y+cjiFnY7FwbvzBjDMb9HRtKJFlAmupg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clusters-dbscan": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/clusters-dbscan/-/clusters-dbscan-7.3.1.tgz", + "integrity": "sha512-rY1wbQlljRhX5e+XM/yw4dKs2HniN45v+Xf5Xde6nv23WyEf/LLjpyD5yrsLa1awfJjD/NmD6axGVebnBBn9YA==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/clusters-kmeans": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/clusters-kmeans/-/clusters-kmeans-7.3.1.tgz", + "integrity": "sha512-HYvRninBY/b5ftkIkoVWjV/wHilNE56cdr6gTlrxuvm4EClilsLDSVYjeiMYU0pjI3xDTc7PlicQDGdnIavUqQ==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "skmeans": "0.9.7", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/collect": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/collect/-/collect-7.3.1.tgz", + "integrity": "sha512-yVDz5YLcRGFipttb60Y4IAd7zWfbQk6mNW5Kt6/wa8+YueHFzsKJdtbErWfozCVuiKplQZWT5r+9J9g6RnhpjQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/combine": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/combine/-/combine-7.3.1.tgz", + "integrity": "sha512-iZBe36sKRq08fY3Ars0JpfYJm8N3LtLLnNzdTxHp8Ry2ORJGHvZHpcv3lQXWL7gyJwDPAye7pyrX7S99IB/1VA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/concave": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/concave/-/concave-7.3.1.tgz", + "integrity": "sha512-vZWqyAYH4qzOuiqPb+bj2jvpIGzYAH8byUhfFJ2gRFRL3/RfV8jdXL2r0Y6VFScqE6OLVGvtM3ITzXX1/9wTaA==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/tin": "7.3.1", + "@types/geojson": "^7946.0.10", + "topojson-client": "3.x", + "topojson-server": "3.x", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/convex": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/convex/-/convex-7.3.1.tgz", + "integrity": "sha512-k2T8QVSie4w+KhwUxjzi/6S6VFr33H9gnUawOh4chCGAgje9PljUZLCGbktHgDfAjX1FVzyUyriH+dm86Z7njQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "concaveman": "^1.2.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/destination": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-7.3.1.tgz", + "integrity": "sha512-yyiJtbQJ4AB9Ny/FKDDNuWI9Sg4Jtd2PMpQPqOV3AFq8NNkg0xJSNmDHDxupb3oPqPWYPxyfVI3tBoF+Xhhoig==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/difference": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/difference/-/difference-7.3.1.tgz", + "integrity": "sha512-Ne2AR+1AdeH8aqY2VHcws+Z/1MHl8SlSbSWHBNVZUVEfvyzTrRg8/E+OC5vFaSUvNZXkB/OUufTCM9xsatLKXQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/dissolve": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/dissolve/-/dissolve-7.3.1.tgz", + "integrity": "sha512-Xmjl4E1aGRMdJjq+HfsiAXZtfMKruq7O+8xvsqnHM6E8iBWlJNSw8ucrNB5RZME8BUojx0q8bvXgS3k68koGyw==", + "license": "MIT", + "dependencies": { + "@turf/flatten": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/distance": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-7.3.1.tgz", + "integrity": "sha512-DK//doTGgYYjBkcWUywAe7wbZYcdP97hdEJ6rXYVYRoULwGGR3lhY96GNjozg6gaW9q2eSNYnZLpcL5iFVHqgw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/distance-weight": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/distance-weight/-/distance-weight-7.3.1.tgz", + "integrity": "sha512-h82qLPeMxOfgN62ZysscQCu9IYB5AO+duw7peAQnMtFobpbcQK58158P0cNzxAoTVJXSO/mfR9dI9Zdz7NF75w==", + "license": "MIT", + "dependencies": { + "@turf/centroid": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/ellipse": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/ellipse/-/ellipse-7.3.1.tgz", + "integrity": "sha512-tcGbS+U7EktZg+UJad17LRU+8C067XDWdmURPCmycaib2zRxeNrImh2Y/589us6wsldlYYoBYRxDY/c1oxIUCA==", + "license": "MIT", + "dependencies": { + "@turf/destination": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/transform-rotate": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/envelope": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/envelope/-/envelope-7.3.1.tgz", + "integrity": "sha512-Sp3ct/LpWyHN5tTfPOcKXFoVDI1QH9BXtQ+aQzABFp3U5nY2Sz8LFg8SeFQm3K7PpoCnUwSfwDFA4aa+z+4l1g==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/bbox-polygon": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/explode": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/explode/-/explode-7.3.1.tgz", + "integrity": "sha512-H0Q8NnmrPoWKhsYYmVmkuT5F4t50N53ByGBf6Ys1n5B9YrFyrT+/aLDXF2C05r+QnW8nFtkM4lFG3ZSBHiq4Xg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/flatten": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/flatten/-/flatten-7.3.1.tgz", + "integrity": "sha512-cM/uuQP8oZ4IDJG342uOlqQ8yD9RsAY9Gg9nsDOgJn6tN065aigRCNy2lfrNyLdK/CPTVEWQzx1EQa+zXGSgAg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/flip": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/flip/-/flip-7.3.1.tgz", + "integrity": "sha512-6sF41pWY8Tw7w72hYc87sR9zzDei7UZ4Db/z0mKuNKueyzl4iTQ/H2JVd/XLZ7Tasz7H8htmrbUO0GR8GY7qiQ==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/geojson-rbush": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/geojson-rbush/-/geojson-rbush-7.3.1.tgz", + "integrity": "sha512-EsrBBftZS5TvzRP2opLzwfnPXfmJi45KkGUcKSSFD0bxQe3BQUSmBrZbHMT8avB2s/XHrS/MniqsyeVOMwc35Q==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/great-circle": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/great-circle/-/great-circle-7.3.1.tgz", + "integrity": "sha512-pfs7PzBRgYEEyecM0ni6iEF19grn9FmbHyaLz7voYInmc2ZHfWQaxuY4dcf9cziWDaiPlbuyr/RyE6envg1xpw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.1.tgz", + "integrity": "sha512-zkL34JVhi5XhsuMEO0MUTIIFEJ8yiW1InMu4hu/oRqamlY4mMoZql0viEmH6Dafh/p+zOl8OYvMJ3Vm3rFshgg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/hex-grid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/hex-grid/-/hex-grid-7.3.1.tgz", + "integrity": "sha512-cWAKxlU1aa06976C3RhpcilDzLnWwXkH/atNIWKGpLV/HubHrMXxhp9VMBKWaqsLbdn5x2uJjv4MxwWw9/373g==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/intersect": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/interpolate": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/interpolate/-/interpolate-7.3.1.tgz", + "integrity": "sha512-dquwDplzkSANMQdvxAu0dRF69EBIIlW/1zTPOB/BQfb/s7j6t8RskgbuV8ew1KpJPMmj7EbexejiMBtRWXTu4Q==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/centroid": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/hex-grid": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/point-grid": "7.3.1", + "@turf/square-grid": "7.3.1", + "@turf/triangle-grid": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/intersect": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-7.3.1.tgz", + "integrity": "sha512-676688YnF9wpprMioQWvxPlUMhtTvYITzw4XoG3lQmLjd/yt2cByanQHWpzWauLfYUlfuL13AeRGdqXRhSkhTQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.1.tgz", + "integrity": "sha512-IdZJfDjIDCLH+Gu2yLFoSM7H23sdetIo5t4ET1/25X8gi3GE2XSqbZwaGjuZgNh02nisBewLqNiJs2bo+hrqZA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/isobands": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/isobands/-/isobands-7.3.1.tgz", + "integrity": "sha512-An6+yUSrOStQSpZwKW9XN891kCW6eagtuofyudZ2BkoxcYRJ0vcDXo7RoiXuf9nHaG4k/xwhAzTqe8hdO1ltWA==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.3.1", + "@turf/bbox": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/explode": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/isolines": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/isolines/-/isolines-7.3.1.tgz", + "integrity": "sha512-TcwbTd7Z4BffYe1PtpXUtZvWCwTffta8VxqryGU30CbqKjNJYqrFbEQXS0mo4l3BEPPmT1lfMskUQ2g97O2MWQ==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/jsts": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@turf/jsts/-/jsts-2.7.2.tgz", + "integrity": "sha512-zAezGlwWHPyU0zxwcX2wQY3RkRpwuoBmhhNE9HY9kWhFDkCxZ3aWK5URKwa/SWKJbj9aztO+8vtdiBA28KVJFg==", + "license": "(EDL-1.0 OR EPL-1.0)", + "dependencies": { + "jsts": "2.7.1" + } + }, + "node_modules/@turf/kinks": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/kinks/-/kinks-7.3.1.tgz", + "integrity": "sha512-gGXNrhlF7zvLwRX672S0Be7bmYjbZEoZYnOGN6RvhyBFSSLFIbne+I74I+lWRzAzG/NhAMBXma5TpB09iTH06Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/length": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/length/-/length-7.3.1.tgz", + "integrity": "sha512-QOr4qS3yi6qWIfQ/KLcy4rDLdemGCYpqz2YDh29R46seE+arSvlBI0KXvI36rPzgEMcUbQuVQyO65sOSqPaEjQ==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-arc": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-arc/-/line-arc-7.3.1.tgz", + "integrity": "sha512-QSuVP0YWcfl76QjPb5Y2GJqXnziSJ2AuaJm5RKEFt5ELugXdEcHkRtydkGov+ZRPmI93jVmXoEE0UXwQx7aYHA==", + "license": "MIT", + "dependencies": { + "@turf/circle": "7.3.1", + "@turf/destination": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-chunk": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-chunk/-/line-chunk-7.3.1.tgz", + "integrity": "sha512-fbJw/7Qlqz0XRMz0TgtFUivFHr51+++ZUBrARgs3w/pogeAdkrcWKBbuT2cowEsUkXDHaQ7MMpmuV8Uteru1qw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/length": "7.3.1", + "@turf/line-slice-along": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-intersect": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-7.3.1.tgz", + "integrity": "sha512-HFPH4Hi+rG7XZ5rijkYL5C9JGVKd6gz6TToShVfqOt/qgGY9/bLYQxymgum/MG7sRhIa8xcKff2d57JrIVuSWA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "sweepline-intersections": "^1.5.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-offset": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-offset/-/line-offset-7.3.1.tgz", + "integrity": "sha512-PyElfSyXETXcI8OKRsAJNdOcxlM718EG0d+b9zeO2uRztf2IlSb5w3lYiTIUSslEDA1gMQE31cJE8sAW40+nhg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-overlap": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-overlap/-/line-overlap-7.3.1.tgz", + "integrity": "sha512-xIhTfPhJMwz57DvM+/JuzG2BUL/gR/pJfH6w+vofI3akej33LTR8b296h2dhcJjDixxprVVH062AD1Q3AGKyfg==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-on-line": "7.3.1", + "@turf/geojson-rbush": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/line-segment": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/nearest-point-on-line": "7.3.1", + "@types/geojson": "^7946.0.10", + "fast-deep-equal": "^3.1.3", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-segment": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-7.3.1.tgz", + "integrity": "sha512-hHz1fM2LigNKmnhyHDXtbRrkBqltH/lYEvhgSmv3laZ9PsEYL8jvA3o7+IhLM9B4KPa8N6VGim6ZR5YA5bhLvQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-slice": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-slice/-/line-slice-7.3.1.tgz", + "integrity": "sha512-bp1L4sc7ZOYC4fwxpfWu+IR/COvLFGm5mjbLPK8VBJYa+kUNrzNcB3QE3A8yFRjwPtlUTCm5fDMLSoGtiJcy2g==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/nearest-point-on-line": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-slice-along": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-slice-along/-/line-slice-along-7.3.1.tgz", + "integrity": "sha512-RizIhPytHxEewCyUCSMrZ5a58sQev0kZ0jzAV/9iTzvGfRD1VU/RG2ThLpSEqXYKBBSty98rTeSlnwsvZpAraA==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.3.1", + "@turf/destination": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-split": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-split/-/line-split-7.3.1.tgz", + "integrity": "sha512-Ee4NRN+eYKYX8vJDNvMpyZFjOntKFokQ/E8yFtKMcN++vG7RbnPOo2/ag6TMZaIHsahj4UR2yhqJbHTaB6Dp+g==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/geojson-rbush": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/line-intersect": "7.3.1", + "@turf/line-segment": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/nearest-point-on-line": "7.3.1", + "@turf/truncate": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/line-to-polygon": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/line-to-polygon/-/line-to-polygon-7.3.1.tgz", + "integrity": "sha512-GL4fjbdYYjfOmwTu4dtllNHm18E7+hoXqyca2Rqb2ZzXj++NHvifJ9iYHUSdpV4/mkvVD3U2rU6jzNkjQeXIaA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/mask": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/mask/-/mask-7.3.1.tgz", + "integrity": "sha512-rSNS6wNuBiaUR1aU7tobgkzHpot5v9GKCn+n5gQ3ad7KWqwwqLWfcCPeyHBWkWEoEwc2yfPqikMQugZbmxrorg==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.1.tgz", + "integrity": "sha512-NWsfOE5RVtWpLQNkfOF/RrYvLRPwwruxhZUV0UFIzHqfiRJ50aO9Y6uLY4bwCUe2TumLJQSR4yaoA72Rmr2mnQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/midpoint": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/midpoint/-/midpoint-7.3.1.tgz", + "integrity": "sha512-hx3eT9ut0Qyl8fyitCREp9l+v5Q4uBILht5+VKQS3p5eK2ijLEsKw4VikNZhh2rZ7bHGrs6obG5/P5ZqDTObiA==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.3.1", + "@turf/destination": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/moran-index": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/moran-index/-/moran-index-7.3.1.tgz", + "integrity": "sha512-9t70AjBB0bycJWLVprqS7mtRU+Ha+U4ji5lkKzyg31ZWAr0IwuawY2VQ/ydsodFMLCqmIf8QbWsltV/I/bRdjQ==", + "license": "MIT", + "dependencies": { + "@turf/distance-weight": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-neighbor-analysis": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/nearest-neighbor-analysis/-/nearest-neighbor-analysis-7.3.1.tgz", + "integrity": "sha512-qwZON/7v1NbD1H1v3kTHJfLLml2/TNj5QQFRFBJiXRSCydMJT1sKEs5BwJe/9cBbmd0ln3gBWXCkG7Sk3sPgOQ==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.3.1", + "@turf/bbox": "7.3.1", + "@turf/bbox-polygon": "7.3.1", + "@turf/centroid": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/nearest-point": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-point": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/nearest-point/-/nearest-point-7.3.1.tgz", + "integrity": "sha512-hLKGFzwAEop5z04X5BeurJvz0oVPHQX0rjeL3v83kgIjR/eavQucXKO3XkJBoF1AaT9Dv0mgB8rmj/qrwroWgg==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-point-on-line": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-7.3.1.tgz", + "integrity": "sha512-FialyHfXXZWLayKQcUtdOtKv3ulOQ9FSI45kSmkDl8b96+VFWHX983Pc94tTrSTSg89+XX7MDr6gRl0yowmF4Q==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/nearest-point-to-line": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-to-line/-/nearest-point-to-line-7.3.1.tgz", + "integrity": "sha512-7zvhE15vlKBW7F3gYmxZMrnsS2HhXIt0Mpdymy6Y1oMWAXrYIqSeHl1Y/h2CiDh0v91K1KJXf2WyRYacosWiNA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/point-to-line-distance": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/planepoint": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/planepoint/-/planepoint-7.3.1.tgz", + "integrity": "sha512-/DVTAZcOsSW54B9XDYUXyiL000vJ8WfONCF4FoM71VMeLS7PM3e+4W9gzN21q15XRn3nUftH12tJhqKEqDouvw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-grid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/point-grid/-/point-grid-7.3.1.tgz", + "integrity": "sha512-KqBlGgBzI/M7/awK25o9p8Q+mRjQDRU4mpHtqNzqNxgidk4JxnUnGybYTnsjp3n1Zid3yASv5kARJ4i/Jc5F7w==", + "license": "MIT", + "dependencies": { + "@turf/boolean-within": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-on-feature": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/point-on-feature/-/point-on-feature-7.3.1.tgz", + "integrity": "sha512-uX15wjujBMeMKAN7OLK4RV6KCLxsoQiFRB9kMtbTeZj13mDo+Bz5SyNN+M2AXqrdsQI9+4h0UTwu3EjcXj/nEw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/center": "7.3.1", + "@turf/explode": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/nearest-point": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-to-line-distance": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/point-to-line-distance/-/point-to-line-distance-7.3.1.tgz", + "integrity": "sha512-vynnX3zIMmJY633fyAIKnzlsmL7OBhbk05YhWVSjCKvSQV8C2xMA9pWaLFacn1xu4nfMSVDUaNOrcAqwubN9pg==", + "license": "MIT", + "dependencies": { + "@turf/bearing": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/nearest-point-on-line": "7.3.1", + "@turf/projection": "7.3.1", + "@turf/rhumb-bearing": "7.3.1", + "@turf/rhumb-distance": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/point-to-polygon-distance": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/point-to-polygon-distance/-/point-to-polygon-distance-7.3.1.tgz", + "integrity": "sha512-A2hTQjMKO2VEMdgOariICLCjt0BDc1wAQ7Mzqc4vFuol1/GlAed4JqyLg1zXuOVlZcojvXDk/XRuZwXDlRJkBA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/point-to-line-distance": "7.3.1", + "@turf/polygon-to-line": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/points-within-polygon": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/points-within-polygon/-/points-within-polygon-7.3.1.tgz", + "integrity": "sha512-tVcQVykc1vvSqz+l/PA4EKVWfMrGtA3ZUxDYBoD2tSaM79EpdTcY1BzfxT5O2582SQ0AdNFXDXRTf7VI6u/+2Q==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygon-smooth": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/polygon-smooth/-/polygon-smooth-7.3.1.tgz", + "integrity": "sha512-CNi4SdpOycZRSBr4o0MlrFdC6x5xcXP6jKx2yXZf9FPrOWamHsDXa+NrywCOAPhgZKnBodRF6usKWudVMyPIgg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygon-tangents": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/polygon-tangents/-/polygon-tangents-7.3.1.tgz", + "integrity": "sha512-XPLeCLQAcU2xco+3kS5Mp4AKmCKjOGzyZoC6oy8BuvHg1HaaEs0ZRzcmf0x17cq7bruhJ7n/QkcudnAueae5mg==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/boolean-within": "7.3.1", + "@turf/explode": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/nearest-point": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygon-to-line": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/polygon-to-line/-/polygon-to-line-7.3.1.tgz", + "integrity": "sha512-qTOFzn7SLQ0TcKBsPFAFYz7iiq34ijqinpjyr9fHQlFHRHeWzUXiWyIn5a2uOHazkdhHCEXNX8JPkt6hjdZ/fQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/polygonize": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/polygonize/-/polygonize-7.3.1.tgz", + "integrity": "sha512-BSamH4eDSbREtye/RZiIyt488KI/hO3+2FiDB8JUoHNESe3VNWk4KEy+sL6oqfhOZcRWndHtJ6MOi3HFptyJrw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/envelope": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/projection": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/projection/-/projection-7.3.1.tgz", + "integrity": "sha512-nDM3LG2j37B1tCpF4xL4rUBrQJcG585IRyDIxL2QEvP1LLv6dcm4fodw70HcGAj05Ux8bJr7IOXQXnobOJrlRA==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/quadrat-analysis": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/quadrat-analysis/-/quadrat-analysis-7.3.1.tgz", + "integrity": "sha512-Kwqtih5CnijULGoTobS0pXdzh/Yr3iGatJcKks4IaxA4+hlJ6Z+Mj47QfKvUtl/IP3lZpVzezewJ51Y989YtVg==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.3.1", + "@turf/bbox": "7.3.1", + "@turf/bbox-polygon": "7.3.1", + "@turf/centroid": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/point-grid": "7.3.1", + "@turf/random": "7.3.1", + "@turf/square-grid": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/random": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/random/-/random-7.3.1.tgz", + "integrity": "sha512-Iruica0gfdAuuqWG3SLe1MQOEP4IOGelPp81Cu552AamhHJmkEZCaiis2n28qdOlAbDs1NJZeJhRFNkiopiy+Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rectangle-grid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/rectangle-grid/-/rectangle-grid-7.3.1.tgz", + "integrity": "sha512-3/fwd1dzeGApxGXAzyVINFylmn8trYTPLG6jtqOgriAdiHPMTtPqSW58wpScC43oKbK3Bps9dSZ43jvcbrfGxw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-intersects": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rewind": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-7.3.1.tgz", + "integrity": "sha512-gD2TGPNq3SE6IlpDwkVHQthZ2U2MElh6X4Vfld3K7VsBHJv4eBct6OOgSWZLkVVPHuWNlVFTNtcRh2LAznMtgw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rhumb-bearing": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/rhumb-bearing/-/rhumb-bearing-7.3.1.tgz", + "integrity": "sha512-GA/EUSOMapLp6qK5kOX+PkFg2MMUHzUSm/jVezv6Fted0dAlCgXHOrKgLm0tN8PqbH7Oj9xQhv9+3/1ze7W8YA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rhumb-destination": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/rhumb-destination/-/rhumb-destination-7.3.1.tgz", + "integrity": "sha512-HjtAFr5DTISUn9b4oaZpX79tYl72r4EyAj40HKwjQeV6KkwIe5/h4zryOSEpnvAK2Gnkmu1GxYeTGfM5z3J9JA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/rhumb-distance": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/rhumb-distance/-/rhumb-distance-7.3.1.tgz", + "integrity": "sha512-9ZvXU0ii2aywdphLhiawl3uxMEHucMmXCBiRj3WhmssTY9CZkFii9iImbJEqz5glxh6/gzXDcz1CCFQUdNP2xA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/sample": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/sample/-/sample-7.3.1.tgz", + "integrity": "sha512-s9IkXrrtaHRllgk9X2tmg8+SJKLG6orQwf0p1wZX8WxnHXvmnHaju465A3nmtGGVDI/RSD8KwU9aqPcc4AinNw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/sector": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/sector/-/sector-7.3.1.tgz", + "integrity": "sha512-3BYJk7pQaqVr1Ji1ors6FUnhCJVHuobNf4bYW2yAUW1rxL+snuo6aTCsu39hpkwLj4BBknYt5w4MIOy5b8+QKg==", + "license": "MIT", + "dependencies": { + "@turf/circle": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/line-arc": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/shortest-path": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/shortest-path/-/shortest-path-7.3.1.tgz", + "integrity": "sha512-B0j6MoTSeGw1inRJPfj+6lU4WVXBNFAafqs/BkccScnCHLLK+vMnsOkyQoDX2vdZnhPTEaGj7TEL1SIjV6IMgA==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/bbox-polygon": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/clean-coords": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/transform-scale": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/simplify": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/simplify/-/simplify-7.3.1.tgz", + "integrity": "sha512-8LRITQAyNAdvVInjm8pal3J7ZAZZBYrYd5oApXqHlIFK7gEiE21Hx9CZyog6AHDjxZCinwnEoGkzDxORh/mNMg==", + "license": "MIT", + "dependencies": { + "@turf/clean-coords": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/square": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/square/-/square-7.3.1.tgz", + "integrity": "sha512-LvMkII6bbHaFHp67jI029xHjWFK3pnqwF8c2pUNU+0dL+45KgrO2jaFTnNQdsjexPymI+uaNLlG809Y0aGGQlw==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/square-grid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/square-grid/-/square-grid-7.3.1.tgz", + "integrity": "sha512-WYCX8+nrqHyAhKBSBHFp1eU1gWrcojz9uVvhCbDO8NO14SLHowzWOgB61Gv8KlLXCUBjDr+rYWCt3ymyPzU5TA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/rectangle-grid": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/standard-deviational-ellipse": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-7.3.1.tgz", + "integrity": "sha512-u9ojpWyv3rnFioYZyya6VXVDrRPYymNROVKwGqnQzffYE1MdxhJ6ik/CvdcChzCNvSNVBJQUvnjjPq2C2uOsLA==", + "license": "MIT", + "dependencies": { + "@turf/center-mean": "7.3.1", + "@turf/ellipse": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/points-within-polygon": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/tag": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/tag/-/tag-7.3.1.tgz", + "integrity": "sha512-Y7G2EWm0/j78ss5wCnjGWKfmPbXw9yKJFg93EuMnwggIsDfKdQi/vdUInjQ0462RIQA87StlydPG09X/8bquwQ==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/tesselate": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/tesselate/-/tesselate-7.3.1.tgz", + "integrity": "sha512-iJnatp9RcJvyffBjqJaw5GbKE/PQosT8DH2kgG7pv4Re0xl3h/QvCjvTlCTEmJ5cNY4geZVKUXDvkkCkgQQVuA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "earcut": "^2.2.4", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/tesselate/node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/@turf/tin": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/tin/-/tin-7.3.1.tgz", + "integrity": "sha512-pDtHE8rLXvV4zAC9mWmwToDDda2ZTty8IZqZIoUqTnlf6AJjzF7TJrhoE3a+zukRTUI1wowTFqe2NvwgNX0yew==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/transform-rotate": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/transform-rotate/-/transform-rotate-7.3.1.tgz", + "integrity": "sha512-KAYebOkk7IT2j7S8M+ZxDAmyqeni9ZZGU9ouD6mvd/hTpDOlGG+ORRmg312RxG0NiThzCHLyeG1Nea1nEud6bg==", + "license": "MIT", + "dependencies": { + "@turf/centroid": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/rhumb-bearing": "7.3.1", + "@turf/rhumb-destination": "7.3.1", + "@turf/rhumb-distance": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/transform-scale": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/transform-scale/-/transform-scale-7.3.1.tgz", + "integrity": "sha512-e8jBSWEn0BMxG0HR8ZMvkHgBgdwNrFRzbhy8DqQwZDgUN59fMeWGbjX5QR5Exl2gZBPaBXkgbDgEhh/JD3kYhw==", + "license": "MIT", + "dependencies": { + "@turf/bbox": "7.3.1", + "@turf/center": "7.3.1", + "@turf/centroid": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/rhumb-bearing": "7.3.1", + "@turf/rhumb-destination": "7.3.1", + "@turf/rhumb-distance": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/transform-translate": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/transform-translate/-/transform-translate-7.3.1.tgz", + "integrity": "sha512-yeaW1EqfuuY4l5VBWSsItglaZ9qdTFD0QEIUW1ooOYuQvtKQ2MTKrcQIKLXZckxQrrNq4TXsZDaBbFs+U1wtcQ==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/rhumb-destination": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/triangle-grid": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/triangle-grid/-/triangle-grid-7.3.1.tgz", + "integrity": "sha512-lhZyqnQC/M8x8DgQURHNZP/HaJIqrL5We5ZvzJBX+lrH2u4DO831awJcuDniRuJ5e0QE5n4yMsBJO77KMNdKfw==", + "license": "MIT", + "dependencies": { + "@turf/distance": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/intersect": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/truncate": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-7.3.1.tgz", + "integrity": "sha512-rcXHM2m17hyKoW1dJpOvTgUUWFOKluTKKsoLmhEE6aRAYwtuVetkcInt4qBtS1bv7MaL//glbvq0kdEGR0YaOA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/turf": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/turf/-/turf-7.3.1.tgz", + "integrity": "sha512-0uKkNnM6Bo6cIzZcJ6wQ+FjFioTFXWS3woGDvQ5R7EPehNfdr4HTS39m1seE+HdI8lGItMZehb6fb0jtjP4Clg==", + "license": "MIT", + "dependencies": { + "@turf/along": "7.3.1", + "@turf/angle": "7.3.1", + "@turf/area": "7.3.1", + "@turf/bbox": "7.3.1", + "@turf/bbox-clip": "7.3.1", + "@turf/bbox-polygon": "7.3.1", + "@turf/bearing": "7.3.1", + "@turf/bezier-spline": "7.3.1", + "@turf/boolean-clockwise": "7.3.1", + "@turf/boolean-concave": "7.3.1", + "@turf/boolean-contains": "7.3.1", + "@turf/boolean-crosses": "7.3.1", + "@turf/boolean-disjoint": "7.3.1", + "@turf/boolean-equal": "7.3.1", + "@turf/boolean-intersects": "7.3.1", + "@turf/boolean-overlap": "7.3.1", + "@turf/boolean-parallel": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/boolean-point-on-line": "7.3.1", + "@turf/boolean-touches": "7.3.1", + "@turf/boolean-valid": "7.3.1", + "@turf/boolean-within": "7.3.1", + "@turf/buffer": "7.3.1", + "@turf/center": "7.3.1", + "@turf/center-mean": "7.3.1", + "@turf/center-median": "7.3.1", + "@turf/center-of-mass": "7.3.1", + "@turf/centroid": "7.3.1", + "@turf/circle": "7.3.1", + "@turf/clean-coords": "7.3.1", + "@turf/clone": "7.3.1", + "@turf/clusters": "7.3.1", + "@turf/clusters-dbscan": "7.3.1", + "@turf/clusters-kmeans": "7.3.1", + "@turf/collect": "7.3.1", + "@turf/combine": "7.3.1", + "@turf/concave": "7.3.1", + "@turf/convex": "7.3.1", + "@turf/destination": "7.3.1", + "@turf/difference": "7.3.1", + "@turf/dissolve": "7.3.1", + "@turf/distance": "7.3.1", + "@turf/distance-weight": "7.3.1", + "@turf/ellipse": "7.3.1", + "@turf/envelope": "7.3.1", + "@turf/explode": "7.3.1", + "@turf/flatten": "7.3.1", + "@turf/flip": "7.3.1", + "@turf/geojson-rbush": "7.3.1", + "@turf/great-circle": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/hex-grid": "7.3.1", + "@turf/interpolate": "7.3.1", + "@turf/intersect": "7.3.1", + "@turf/invariant": "7.3.1", + "@turf/isobands": "7.3.1", + "@turf/isolines": "7.3.1", + "@turf/kinks": "7.3.1", + "@turf/length": "7.3.1", + "@turf/line-arc": "7.3.1", + "@turf/line-chunk": "7.3.1", + "@turf/line-intersect": "7.3.1", + "@turf/line-offset": "7.3.1", + "@turf/line-overlap": "7.3.1", + "@turf/line-segment": "7.3.1", + "@turf/line-slice": "7.3.1", + "@turf/line-slice-along": "7.3.1", + "@turf/line-split": "7.3.1", + "@turf/line-to-polygon": "7.3.1", + "@turf/mask": "7.3.1", + "@turf/meta": "7.3.1", + "@turf/midpoint": "7.3.1", + "@turf/moran-index": "7.3.1", + "@turf/nearest-neighbor-analysis": "7.3.1", + "@turf/nearest-point": "7.3.1", + "@turf/nearest-point-on-line": "7.3.1", + "@turf/nearest-point-to-line": "7.3.1", + "@turf/planepoint": "7.3.1", + "@turf/point-grid": "7.3.1", + "@turf/point-on-feature": "7.3.1", + "@turf/point-to-line-distance": "7.3.1", + "@turf/point-to-polygon-distance": "7.3.1", + "@turf/points-within-polygon": "7.3.1", + "@turf/polygon-smooth": "7.3.1", + "@turf/polygon-tangents": "7.3.1", + "@turf/polygon-to-line": "7.3.1", + "@turf/polygonize": "7.3.1", + "@turf/projection": "7.3.1", + "@turf/quadrat-analysis": "7.3.1", + "@turf/random": "7.3.1", + "@turf/rectangle-grid": "7.3.1", + "@turf/rewind": "7.3.1", + "@turf/rhumb-bearing": "7.3.1", + "@turf/rhumb-destination": "7.3.1", + "@turf/rhumb-distance": "7.3.1", + "@turf/sample": "7.3.1", + "@turf/sector": "7.3.1", + "@turf/shortest-path": "7.3.1", + "@turf/simplify": "7.3.1", + "@turf/square": "7.3.1", + "@turf/square-grid": "7.3.1", + "@turf/standard-deviational-ellipse": "7.3.1", + "@turf/tag": "7.3.1", + "@turf/tesselate": "7.3.1", + "@turf/tin": "7.3.1", + "@turf/transform-rotate": "7.3.1", + "@turf/transform-scale": "7.3.1", + "@turf/transform-translate": "7.3.1", + "@turf/triangle-grid": "7.3.1", + "@turf/truncate": "7.3.1", + "@turf/union": "7.3.1", + "@turf/unkink-polygon": "7.3.1", + "@turf/voronoi": "7.3.1", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/union": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/union/-/union-7.3.1.tgz", + "integrity": "sha512-Fk8HvP2gRrRJz8xefeoFJJUeLwhih3HoPPKlqaDf/6L43jwAzBD6BPu59+AwRXOlaZeOUMNMGzgSgx0KKrBwBg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "polyclip-ts": "^0.16.8", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/unkink-polygon": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/unkink-polygon/-/unkink-polygon-7.3.1.tgz", + "integrity": "sha512-6NVFkCpJUT2P4Yf3z/FI2uGDXqVdEqZqKGl2hYitmH7mNiKhU4bAvvcw7nCSfNG3sUyNhibbtOEopYMRgwimPw==", + "license": "MIT", + "dependencies": { + "@turf/area": "7.3.1", + "@turf/boolean-point-in-polygon": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/meta": "7.3.1", + "@types/geojson": "^7946.0.10", + "rbush": "^3.0.1", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/voronoi": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@turf/voronoi/-/voronoi-7.3.1.tgz", + "integrity": "sha512-yS+0EDwSIOizEXI+05qixw/OGZalpfsz9xzBWbCBA3Gu2boLMXErFZ73qzfu39Vwk+ILbu5em0p+VhULBzvH9w==", + "license": "MIT", + "dependencies": { + "@turf/clone": "7.3.1", + "@turf/helpers": "7.3.1", + "@turf/invariant": "7.3.1", + "@types/d3-voronoi": "^1.1.12", + "@types/geojson": "^7946.0.10", + "d3-voronoi": "1.1.2", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1486,6 +3519,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-voronoi": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.12.tgz", + "integrity": "sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1981,6 +4020,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2115,6 +4163,12 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2122,6 +4176,24 @@ "dev": true, "license": "MIT" }, + "node_modules/concaveman": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/concaveman/-/concaveman-1.2.1.tgz", + "integrity": "sha512-PwZYKaM/ckQSa8peP5JpVr7IMJ4Nn/MHIaWUjP4be+KoZ7Botgs8seAZGpmaOM+UZXawcdYRao/px9ycrCihHw==", + "license": "ISC", + "dependencies": { + "point-in-polygon": "^1.1.0", + "rbush": "^3.0.1", + "robust-predicates": "^2.0.4", + "tinyqueue": "^2.0.3" + } + }, + "node_modules/concaveman/node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2184,6 +4256,27 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", + "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/d3-voronoi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.2.tgz", + "integrity": "sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==", + "license": "BSD-3-Clause" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2453,7 +4546,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2614,6 +4706,39 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-equality-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/geojson-equality-ts/-/geojson-equality-ts-1.0.2.tgz", + "integrity": "sha512-h3Ryq+0mCSN/7yLs0eDgrZhvc9af23o/QuC4aTiuuzP/MRCtd6mf5rLsLRY44jX0RPUfM8c4GqERQmlUxPGPoQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.14" + } + }, + "node_modules/geojson-polygon-self-intersections": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/geojson-polygon-self-intersections/-/geojson-polygon-self-intersections-1.2.2.tgz", + "integrity": "sha512-6XRNF4CsRHYmR9z5YuIk5f/aOototnDf0dgMqYGcS7y1l57ttt6MAIAxl3rXyas6lq1HEbTuLMh4PgvO+OV42w==", + "license": "MIT", + "dependencies": { + "rbush": "^2.0.1" + } + }, + "node_modules/geojson-polygon-self-intersections/node_modules/quickselect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-1.1.1.tgz", + "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==", + "license": "ISC" + }, + "node_modules/geojson-polygon-self-intersections/node_modules/rbush": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-2.0.2.tgz", + "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", + "license": "MIT", + "dependencies": { + "quickselect": "^1.0.1" + } + }, "node_modules/geojson-vt": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", @@ -2882,6 +5007,15 @@ "node": ">=6" } }, + "node_modules/jsts": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/jsts/-/jsts-2.7.1.tgz", + "integrity": "sha512-x2wSZHEBK20CY+Wy+BPE7MrFQHW6sIsdaGUMEqmGAio+3gFzQaBYPwLRonUfQf9Ak8pBieqj9tUofX1+WtAEIg==", + "license": "(EDL-1.0 OR EPL-1.0)", + "engines": { + "node": ">= 12" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -3514,6 +5648,37 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/point-in-polygon-hao": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", + "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/point-in-polygon-hao/node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/polyclip-ts": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz", + "integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.1.0", + "splaytree-ts": "^1.0.2" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3619,6 +5784,21 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/rbush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz", + "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==", + "license": "MIT", + "dependencies": { + "quickselect": "^2.0.0" + } + }, + "node_modules/rbush/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -3757,6 +5937,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.0-beta.41", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.41.tgz", @@ -3873,6 +6059,12 @@ "node": ">=8" } }, + "node_modules/skmeans": { + "version": "0.9.7", + "resolved": "https://registry.npmjs.org/skmeans/-/skmeans-0.9.7.tgz", + "integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -3892,6 +6084,12 @@ "node": ">=0.10.0" } }, + "node_modules/splaytree-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz", + "integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==", + "license": "BDS-3-Clause" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3945,6 +6143,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sweepline-intersections": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz", + "integrity": "sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==", + "license": "MIT", + "dependencies": { + "tinyqueue": "^2.0.0" + } + }, + "node_modules/sweepline-intersections/node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4012,6 +6225,32 @@ "node": ">=8.0" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-server": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/topojson-server/-/topojson-server-3.0.1.tgz", + "integrity": "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "geo2topo": "bin/geo2topo" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4029,9 +6268,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/frontend/package.json b/frontend/package.json index 51f9a42..6a3bebc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", + "@turf/turf": "^7.3.1", "leaflet": "^1.9.4", "maplibre-gl": "^5.15.0", "react": "^19.2.0", From 43523e1dde705b1c902d436f9a7f03a38f92c7a9 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 16:17:14 -0700 Subject: [PATCH 21/25] feat: improve map visualization --- .../PrescriptionMapViewer.tsx | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx index a427be9..a9694f4 100644 --- a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx @@ -1,5 +1,5 @@ import { Box, Typography, Button, Container } from "@mui/material"; -import type { FeatureCollection } from 'geojson'; +import type { Feature, FeatureCollection, Point, Polygon } from 'geojson'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { COLORS } from '../../styles/colors'; @@ -9,6 +9,7 @@ import { samplePrescriptionData } from "../../samplePrescriptionData"; import { computeBoundsFromGeoJSON } from '../../types/maplibre/bounds'; import { priorityFillColorExpression } from '../../types/maplibre/priorityStyle'; import { useCoordinates } from '../../contexts/CoordinateContext'; +import * as turf from '@turf/turf'; interface PrescriptionMapViewerProps { /** A GeoJSON FeatureCollection from the backend. Expect the outer polygon (farm boundary) @@ -26,6 +27,9 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const popupRef = React.useRef(null); + const pointsSourceId = 'grid-points'; + const pointsLayerId = 'grid-points-circle'; + const [gridPoints, setGridPoints] = React.useState>({ type: 'FeatureCollection', features: [] }); React.useEffect(() => { if (mapContainerRef.current && !mapRef.current) { @@ -69,6 +73,40 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h }, }); + // Points source/layer for grid visualization + map.addSource(pointsSourceId, { + type: 'geojson', + data: gridPoints as any, + }); + + map.addLayer({ + id: pointsLayerId, + type: 'circle', + source: pointsSourceId, + paint: { + 'circle-color': [ + 'case', + ['has', 'paybackPeriod'], + ['step', ['get', 'paybackPeriod'], + '#1a9641', + 2, '#a6d96a', + 4, '#f9d423', + 6, '#f58634', + 8, '#d7191c' + ], + '#999999' + ], + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 4, ['+', 3, ['coalesce', ['get', 'applicationRate'], 5]], + 10, ['+', 1, ['coalesce', ['get', 'applicationRate'], 5]] + ], + 'circle-opacity': 0.85, + 'circle-stroke-color': '#111', + 'circle-stroke-width': 0.5, + }, + }); + popupRef.current = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 }); map.on('mouseenter', fillLayerId, () => { @@ -125,6 +163,78 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h } }, [data]); + // Utility: compute N x N resolution based on bbox + function computeResolution(polygon: Feature, targetN = 20) { + const bbox = turf.bbox(polygon); + const [minX, minY, maxX, maxY] = bbox; + const widthDeg = Math.abs(maxX - minX); + const heightDeg = Math.abs(maxY - minY); + // Choose spacing so that roughly targetN points per side + const spacingX = widthDeg / targetN; + const spacingY = heightDeg / targetN; + // Use min spacing to avoid huge counts if aspect ratio extreme + const spacing = Math.min(spacingX, spacingY); + return Math.max(spacing, 0.0001); // guard minimal spacing + } + + // Generate points inside polygon + function regenerateGrid(polygonFC: FeatureCollection) { + const polyFeature = polygonFC.features.find(f => f.geometry.type === 'Polygon') as Feature | undefined; + if (!polyFeature) { + setGridPoints({ type: 'FeatureCollection', features: [] }); + return; + } + const spacing = computeResolution(polyFeature, 20); + const bbox = turf.bbox(polyFeature); + const pointGrid = turf.pointGrid(bbox, spacing, { mask: polyFeature }); + // Assign properties: applicationRate constant, paybackPeriod random 1-10 + const features = pointGrid.features.map((pt) => { + const applicationRate = 5; + const paybackPeriod = Math.floor(Math.random() * 10) + 1; + return { + ...pt, + properties: { + ...(pt.properties || {}), + applicationRate, + paybackPeriod, + } + } as Feature; + }); + const fc: FeatureCollection = { type: 'FeatureCollection', features }; + setGridPoints(fc); + const map = mapRef.current; + if (map) { + const src = map.getSource(pointsSourceId) as maplibregl.GeoJSONSource | undefined; + if (src) src.setData(fc as any); + } + } + + // Recompute when the polygon changes (committed coords only) + React.useEffect(() => { + if (!hasCoordinates || !formSubmitted) return; + // If incoming data contains polygon, use that; otherwise read from context committed state + if (data) { + regenerateGrid(data); + } + }, [data, hasCoordinates, formSubmitted]); + + // Recompute on map move/zoom to adapt circle sizing if necessary (optional) + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + const handler = () => { + if (hasCoordinates && formSubmitted && data) { + regenerateGrid(data); + } + }; + map.on('moveend', handler); + map.on('zoomend', handler); + return () => { + map.off('moveend', handler); + map.off('zoomend', handler); + }; + }, [hasCoordinates, formSubmitted, data]); + return ( {/* Guard: require committed coords AND submitted form */} From 72441ad787ae0dd9d4189cb91da2f52090774f67 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 16:37:47 -0700 Subject: [PATCH 22/25] feat: link output map to input coordinates --- .../PrescriptionMapViewer.tsx | 205 ++++++++++-------- 1 file changed, 114 insertions(+), 91 deletions(-) diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx index a9694f4..2eda51a 100644 --- a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx @@ -1,5 +1,5 @@ import { Box, Typography, Button, Container } from "@mui/material"; -import type { Feature, FeatureCollection, Point, Polygon } from 'geojson'; +import type { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { COLORS } from '../../styles/colors'; @@ -23,13 +23,23 @@ interface PrescriptionMapViewerProps { export default function PrescriptionMapViewer({ data = samplePrescriptionData, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { const navigate = useNavigate(); - const { hasCoordinates, isLoading, clearCoordinateData, formSubmitted, setFormSubmitted } = useCoordinates(); + const { data: committedCoords, hasCoordinates, isLoading, clearCoordinateData, formSubmitted, setFormSubmitted } = useCoordinates(); const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const popupRef = React.useRef(null); - const pointsSourceId = 'grid-points'; - const pointsLayerId = 'grid-points-circle'; - const [gridPoints, setGridPoints] = React.useState>({ type: 'FeatureCollection', features: [] }); + const gridCellsSourceId = 'grid-cells'; + const gridCellsFillLayerId = 'grid-cells-fill'; + const gridCellsOutlineLayerId = 'grid-cells-outline'; + const [gridCells, setGridCells] = React.useState>({ type: 'FeatureCollection', features: [] }); + + // Use explicit data prop for testing; otherwise use committed coordinates from context after user submits + // This ensures the grid only shows the user's final selected polygon after submission + const polygonData = React.useMemo(() => { + if (data && data !== samplePrescriptionData) return data as FeatureCollection; + if (committedCoords) return committedCoords; + if (data) return data as FeatureCollection; // fallback to prop/sample + return null; + }, [data, committedCoords]); React.useEffect(() => { if (mapContainerRef.current && !mapRef.current) { @@ -49,7 +59,7 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h map.on('load', () => { map.addSource(sourceId, { type: 'geojson', - data: (data ?? samplePrescriptionData) as any, + data: (polygonData ?? samplePrescriptionData) as any, }); map.addLayer({ @@ -73,37 +83,37 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h }, }); - // Points source/layer for grid visualization - map.addSource(pointsSourceId, { + // Grid cells source/layers for square visualization + map.addSource(gridCellsSourceId, { type: 'geojson', - data: gridPoints as any, + data: gridCells as any, }); map.addLayer({ - id: pointsLayerId, - type: 'circle', - source: pointsSourceId, + id: gridCellsFillLayerId, + type: 'fill', + source: gridCellsSourceId, paint: { - 'circle-color': [ - 'case', - ['has', 'paybackPeriod'], - ['step', ['get', 'paybackPeriod'], - '#1a9641', - 2, '#a6d96a', - 4, '#f9d423', - 6, '#f58634', - 8, '#d7191c' - ], - '#999999' + 'fill-color': [ + 'step', ['get', 'paybackPeriod'], + '#1a9641', + 2, '#a6d96a', + 4, '#f9d423', + 6, '#f58634', + 8, '#d7191c' ], - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 4, ['+', 3, ['coalesce', ['get', 'applicationRate'], 5]], - 10, ['+', 1, ['coalesce', ['get', 'applicationRate'], 5]] - ], - 'circle-opacity': 0.85, - 'circle-stroke-color': '#111', - 'circle-stroke-width': 0.5, + 'fill-opacity': 0.6, + }, + }); + + map.addLayer({ + id: gridCellsOutlineLayerId, + type: 'line', + source: gridCellsSourceId, + paint: { + 'line-color': '#111', + 'line-width': 1, + 'line-opacity': 0.8, }, }); @@ -129,8 +139,8 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h popupRef.current && popupRef.current.remove(); }); - if (data) { - const b = computeBoundsFromGeoJSON(data); + if (polygonData) { + const b = computeBoundsFromGeoJSON(polygonData); if (b) { map.fitBounds(b, { padding: 20 }); } @@ -151,89 +161,102 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h }, []); React.useEffect(() => { - const map = mapRef.current; - if (!map) return; - const source = map.getSource('prescription-data') as maplibregl.GeoJSONSource | undefined; - if (source && data) { - source.setData(data as any); - const b = computeBoundsFromGeoJSON(data); + const mapInstance = mapRef.current; + if (!mapInstance) return; + const source = mapInstance.getSource('prescription-data') as maplibregl.GeoJSONSource | undefined; + if (source && polygonData) { + source.setData(polygonData as any); + const b = computeBoundsFromGeoJSON(polygonData); if (b) { - map.fitBounds(b, { padding: 20 }); + mapInstance.fitBounds(b, { padding: 20 }); } } - }, [data]); + }, [polygonData]); - // Utility: compute N x N resolution based on bbox - function computeResolution(polygon: Feature, targetN = 20) { + // Utility: compute N x N resolution based on bbox (kilometers per side) + function computeResolutionKm(polygon: Feature, targetN = 20) { const bbox = turf.bbox(polygon); const [minX, minY, maxX, maxY] = bbox; - const widthDeg = Math.abs(maxX - minX); - const heightDeg = Math.abs(maxY - minY); - // Choose spacing so that roughly targetN points per side - const spacingX = widthDeg / targetN; - const spacingY = heightDeg / targetN; - // Use min spacing to avoid huge counts if aspect ratio extreme - const spacing = Math.min(spacingX, spacingY); - return Math.max(spacing, 0.0001); // guard minimal spacing + const horizontalKm = turf.distance([minX, minY], [maxX, minY], { units: 'kilometers' }); + const verticalKm = turf.distance([minX, minY], [minX, maxY], { units: 'kilometers' }); + const cellSideKm = Math.min(horizontalKm / targetN, verticalKm / targetN); + return Math.max(cellSideKm, 0.05); // guard minimal spacing (~50m) } - // Generate points inside polygon - function regenerateGrid(polygonFC: FeatureCollection) { - const polyFeature = polygonFC.features.find(f => f.geometry.type === 'Polygon') as Feature | undefined; + // Generate square grid cells inside polygon + function regenerateGrid(polygonFC: FeatureCollection | null) { + if (!polygonFC || !polygonFC.features || polygonFC.features.length === 0) { + setGridCells({ type: 'FeatureCollection', features: [] }); + return; + } + + const polyFeature = polygonFC.features.find( + f => f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon' + ) as Feature | undefined; if (!polyFeature) { - setGridPoints({ type: 'FeatureCollection', features: [] }); + setGridCells({ type: 'FeatureCollection', features: [] }); return; } - const spacing = computeResolution(polyFeature, 20); + + const cellSideKm = computeResolutionKm(polyFeature, 20); const bbox = turf.bbox(polyFeature); - const pointGrid = turf.pointGrid(bbox, spacing, { mask: polyFeature }); - // Assign properties: applicationRate constant, paybackPeriod random 1-10 - const features = pointGrid.features.map((pt) => { - const applicationRate = 5; - const paybackPeriod = Math.floor(Math.random() * 10) + 1; - return { - ...pt, - properties: { - ...(pt.properties || {}), - applicationRate, - paybackPeriod, - } - } as Feature; + const rawGrid = turf.squareGrid(bbox, cellSideKm, { units: 'kilometers' }); + + const features: Feature[] = []; + rawGrid.features.forEach((cell) => { + if (!cell || !cell.geometry) return; + if (cell.geometry.type !== 'Polygon' && cell.geometry.type !== 'MultiPolygon') return; + try { + const clipped = turf.intersect(cell as any, polyFeature as any) as Feature | null; + if (!clipped) return; + if (clipped.geometry.type !== 'Polygon' && clipped.geometry.type !== 'MultiPolygon') return; + const applicationRate = 5; + const paybackPeriod = Math.floor(Math.random() * 10) + 1; + features.push({ + ...clipped, + properties: { + ...(clipped.properties || {}), + applicationRate, + paybackPeriod, + } + }); + } catch (err) { + console.warn('Skipping grid cell due to clip error', err); + } }); - const fc: FeatureCollection = { type: 'FeatureCollection', features }; - setGridPoints(fc); + + const fc: FeatureCollection = { type: 'FeatureCollection', features }; + setGridCells(fc); + const map = mapRef.current; if (map) { - const src = map.getSource(pointsSourceId) as maplibregl.GeoJSONSource | undefined; + const src = map.getSource(gridCellsSourceId) as maplibregl.GeoJSONSource | undefined; if (src) src.setData(fc as any); } } - // Recompute when the polygon changes (committed coords only) + // Regenerate grid only when user has submitted final coordinates + // This triggers after the farm info modal is submitted and committed to context React.useEffect(() => { - if (!hasCoordinates || !formSubmitted) return; - // If incoming data contains polygon, use that; otherwise read from context committed state - if (data) { - regenerateGrid(data); + if (!formSubmitted) { + // Clear grid if user hasn't submitted yet + setGridCells({ type: 'FeatureCollection', features: [] }); + return; + } + if (polygonData) { + regenerateGrid(polygonData); } - }, [data, hasCoordinates, formSubmitted]); + }, [formSubmitted, polygonData]); - // Recompute on map move/zoom to adapt circle sizing if necessary (optional) + // Sync grid cells to map source when they change React.useEffect(() => { const map = mapRef.current; if (!map) return; - const handler = () => { - if (hasCoordinates && formSubmitted && data) { - regenerateGrid(data); - } - }; - map.on('moveend', handler); - map.on('zoomend', handler); - return () => { - map.off('moveend', handler); - map.off('zoomend', handler); - }; - }, [hasCoordinates, formSubmitted, data]); + const src = map.getSource(gridCellsSourceId) as maplibregl.GeoJSONSource | undefined; + if (src) { + src.setData(gridCells as any); + } + }, [gridCells]); return ( From 7a9d7d8aac01257b54e45570a94f86d3ff7d38a4 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 20:06:11 -0700 Subject: [PATCH 23/25] feat: finish working output, --- .../InteractiveFarmMap/InteractiveFarmMap.tsx | 19 +- .../src/components/ManualCoordinateUpload.tsx | 2 +- .../PrescriptionMapViewer.tsx | 603 +++++++++++------- frontend/src/contexts/CoordinateContext.tsx | 60 +- frontend/src/samplePrescriptionData.ts | 82 --- 5 files changed, 451 insertions(+), 315 deletions(-) delete mode 100644 frontend/src/samplePrescriptionData.ts diff --git a/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx b/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx index b2758ad..1353446 100644 --- a/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx +++ b/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx @@ -43,13 +43,30 @@ export default function InteractiveFarmMap({ markers, setMarkers }: InteractiveF zoom={6} style={{ height: "100%", width: "100%" }} > - + {/* ESRI Satellite Imagery */} + + + {/* ESRI Transportation Layer */} + + + {/* ESRI City Labels Layer */} + {markers.map((position, idx) => ( ))} + {markers.length >= 3 && } diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/components/ManualCoordinateUpload.tsx index a3fe8a0..1cffaa1 100644 --- a/frontend/src/components/ManualCoordinateUpload.tsx +++ b/frontend/src/components/ManualCoordinateUpload.tsx @@ -52,7 +52,7 @@ export default function ManualCoordinateUpload() { const boundary: Feature = { type: 'Feature', - properties: { type: 'boundary' }, + properties: { type: 'boundary', applicationRate: 5, paybackPeriod: 3 }, geometry: { type: 'Polygon', coordinates: [coords], diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx index 2eda51a..4dbe664 100644 --- a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx @@ -1,266 +1,418 @@ import { Box, Typography, Button, Container } from "@mui/material"; -import type { Feature, FeatureCollection, MultiPolygon, Polygon } from 'geojson'; -import maplibregl from 'maplibre-gl'; -import 'maplibre-gl/dist/maplibre-gl.css'; import { COLORS } from '../../styles/colors'; import React from "react"; import { useNavigate } from 'react-router'; -import { samplePrescriptionData } from "../../samplePrescriptionData"; -import { computeBoundsFromGeoJSON } from '../../types/maplibre/bounds'; -import { priorityFillColorExpression } from '../../types/maplibre/priorityStyle'; import { useCoordinates } from '../../contexts/CoordinateContext'; -import * as turf from '@turf/turf'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; + +type LatLng = { lat: number; lng: number }; + +interface GridCell { + bounds: L.LatLngBounds; + paybackPeriod: number; + applicationRate: number; +} interface PrescriptionMapViewerProps { - /** A GeoJSON FeatureCollection from the backend. Expect the outer polygon (farm boundary) - * and inner polygons (prescription zones) with properties such as - * { applicationRate: number, paybackPeriod: number } - */ - data?: FeatureCollection | null; + data?: LatLng[] | null; height?: number | string; width?: number | string; } -export default function PrescriptionMapViewer({ data = samplePrescriptionData, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { - const navigate = useNavigate(); - const { data: committedCoords, hasCoordinates, isLoading, clearCoordinateData, formSubmitted, setFormSubmitted } = useCoordinates(); - const mapContainerRef = React.useRef(null); - const mapRef = React.useRef(null); - const popupRef = React.useRef(null); - const gridCellsSourceId = 'grid-cells'; - const gridCellsFillLayerId = 'grid-cells-fill'; - const gridCellsOutlineLayerId = 'grid-cells-outline'; - const [gridCells, setGridCells] = React.useState>({ type: 'FeatureCollection', features: [] }); - - // Use explicit data prop for testing; otherwise use committed coordinates from context after user submits - // This ensures the grid only shows the user's final selected polygon after submission - const polygonData = React.useMemo(() => { - if (data && data !== samplePrescriptionData) return data as FeatureCollection; - if (committedCoords) return committedCoords; - if (data) return data as FeatureCollection; // fallback to prop/sample - return null; - }, [data, committedCoords]); - - React.useEffect(() => { - if (mapContainerRef.current && !mapRef.current) { - const map = new maplibregl.Map({ - container: mapContainerRef.current, - style: 'https://demotiles.maplibre.org/style.json', - center: [-110, 44.5], - zoom: 4, - }); - - mapRef.current = map; - - const sourceId = 'prescription-data'; - const fillLayerId = 'prescription-fill'; - const outlineLayerId = 'prescription-outline'; - - map.on('load', () => { - map.addSource(sourceId, { - type: 'geojson', - data: (polygonData ?? samplePrescriptionData) as any, +// Color scale for payback period (1-10) +const getColorForPayback = (paybackPeriod: number): string => { + if (paybackPeriod <= 2) return '#1a9641'; // green + if (paybackPeriod <= 4) return '#a6d96a'; // light green + if (paybackPeriod <= 6) return '#f9d423'; // yellow + if (paybackPeriod <= 8) return '#f58634'; // orange + return '#d7191c'; // red +}; + +// Point-in-polygon test using ray casting algorithm +const pointInPolygon = (point: LatLng, polygon: LatLng[]): boolean => { + let inside = false; + const x = point.lng; + const y = point.lat; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].lng; + const yi = polygon[i].lat; + const xj = polygon[j].lng; + const yj = polygon[j].lat; + + if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) { + inside = !inside; + } + } + + return inside; +}; + +// Convert meters to degrees (approximate) +const metersToDegreesLat = (meters: number): number => meters / 111320; +const metersToDegreesLng = (meters: number, lat: number): number => + meters / (111320 * Math.cos(lat * Math.PI / 180)); + +// Generate grid cells inside a polygon boundary +const generateGridCells = (boundary: LatLng[], cellSizeMeters: number): GridCell[] => { + if (boundary.length < 3) return []; + + // Find bounding box + let minLat = Infinity, maxLat = -Infinity; + let minLng = Infinity, maxLng = -Infinity; + + for (const pt of boundary) { + minLat = Math.min(minLat, pt.lat); + maxLat = Math.max(maxLat, pt.lat); + minLng = Math.min(minLng, pt.lng); + maxLng = Math.max(maxLng, pt.lng); + } + + const centerLat = (minLat + maxLat) / 2; + const cellSizeLat = metersToDegreesLat(cellSizeMeters); + const cellSizeLng = metersToDegreesLng(cellSizeMeters, centerLat); + + const cells: GridCell[] = []; + + // Generate grid cells + for (let lat = minLat; lat < maxLat; lat += cellSizeLat) { + for (let lng = minLng; lng < maxLng; lng += cellSizeLng) { + // Check if cell center is inside polygon + const cellCenter: LatLng = { + lat: lat + cellSizeLat / 2, + lng: lng + cellSizeLng / 2, + }; + + if (pointInPolygon(cellCenter, boundary)) { + const bounds = L.latLngBounds( + [lat, lng], + [lat + cellSizeLat, lng + cellSizeLng] + ); + + cells.push({ + bounds, + paybackPeriod: Math.floor(Math.random() * 10) + 1, + applicationRate: 5, }); + } + } + } + + return cells; +}; + +class GridCanvasLayer extends L.Layer { + private _canvas: HTMLCanvasElement | null = null; + private _cells: GridCell[] = []; + private _mapInstance: L.Map | null = null; + private _onHover: ((cell: GridCell | null, e: MouseEvent) => void) | null = null; + private _hoveredCell: GridCell | null = null; + + constructor(cells: GridCell[], onHover?: (cell: GridCell | null, e: MouseEvent) => void) { + super(); + this._cells = cells; + this._onHover = onHover || null; + } - map.addLayer({ - id: fillLayerId, - type: 'fill', - source: sourceId, - paint: { - 'fill-color': priorityFillColorExpression as any, - 'fill-opacity': 0.6, - }, - }); + onAdd(map: L.Map): this { + this._mapInstance = map; - map.addLayer({ - id: outlineLayerId, - type: 'line', - source: sourceId, - paint: { - 'line-color': '#222', - 'line-width': 1.5, - 'line-opacity': 0.9, - }, - }); + // Main canvas + this._canvas = L.DomUtil.create('canvas', 'leaflet-grid-canvas') as HTMLCanvasElement; + this._canvas.style.position = 'absolute'; + this._canvas.style.pointerEvents = 'auto'; - // Grid cells source/layers for square visualization - map.addSource(gridCellsSourceId, { - type: 'geojson', - data: gridCells as any, - }); + const pane = map.getPane('overlayPane'); + if (pane) pane.appendChild(this._canvas); - map.addLayer({ - id: gridCellsFillLayerId, - type: 'fill', - source: gridCellsSourceId, - paint: { - 'fill-color': [ - 'step', ['get', 'paybackPeriod'], - '#1a9641', - 2, '#a6d96a', - 4, '#f9d423', - 6, '#f58634', - 8, '#d7191c' - ], - 'fill-opacity': 0.6, - }, - }); + // Events - include 'move' for smooth panning + map.on('move moveend zoomend resize', this._reset, this); + this._canvas.addEventListener('mousemove', this._onMouseMove.bind(this)); + this._canvas.addEventListener('mouseout', this._onMouseOut.bind(this)); - map.addLayer({ - id: gridCellsOutlineLayerId, - type: 'line', - source: gridCellsSourceId, - paint: { - 'line-color': '#111', - 'line-width': 1, - 'line-opacity': 0.8, - }, - }); + this._reset(); + return this; + } - popupRef.current = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 }); + onRemove(map: L.Map): this { + if (this._canvas?.parentNode) this._canvas.parentNode.removeChild(this._canvas); + map.off('move moveend zoomend resize', this._reset, this); + this._canvas = null; + this._mapInstance = null; + return this; + } - map.on('mouseenter', fillLayerId, () => { - map.getCanvas().style.cursor = 'pointer'; - }); + setCells(cells: GridCell[]): void { + this._cells = cells; + this._reset(); + } - map.on('mousemove', fillLayerId, (e) => { - const feature = e.features && e.features[0]; - const props: any = feature?.properties || {}; - const applicationRate = props.applicationRate ?? props.rate ?? 'n/a'; - const payback = props.paybackPeriod ?? 'n/a'; - const html = `
Payback Period: ${payback}
Application Rate: ${applicationRate}
`; - if (popupRef.current) { - popupRef.current.setLngLat(e.lngLat).setHTML(html).addTo(map); - } - }); + private _reset(): void { + if (!this._mapInstance || !this._canvas) return; - map.on('mouseleave', fillLayerId, () => { - map.getCanvas().style.cursor = ''; - popupRef.current && popupRef.current.remove(); - }); + const size = this._mapInstance.getSize(); + const bounds = this._mapInstance.getBounds(); + const topLeft = this._mapInstance.latLngToLayerPoint(bounds.getNorthWest()); - if (polygonData) { - const b = computeBoundsFromGeoJSON(polygonData); - if (b) { - map.fitBounds(b, { padding: 20 }); - } - } - }); - } + this._canvas.width = size.x; + this._canvas.height = size.y; + this._canvas.style.width = `${size.x}px`; + this._canvas.style.height = `${size.y}px`; + L.DomUtil.setPosition(this._canvas, topLeft); - return () => { - if (popupRef.current) { - popupRef.current.remove(); - popupRef.current = null; - } - if (mapRef.current) { - mapRef.current.remove(); - mapRef.current = null; - } - }; - }, []); + this._draw(); + } - React.useEffect(() => { - const mapInstance = mapRef.current; - if (!mapInstance) return; - const source = mapInstance.getSource('prescription-data') as maplibregl.GeoJSONSource | undefined; - if (source && polygonData) { - source.setData(polygonData as any); - const b = computeBoundsFromGeoJSON(polygonData); - if (b) { - mapInstance.fitBounds(b, { padding: 20 }); + private _draw(): void { + if (!this._mapInstance || !this._canvas) return; + const ctx = this._canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + + if (this._cells.length === 0) return; + + // Get visible bounds for culling + const viewBounds = this._mapInstance.getBounds(); + + // Draw all cells (already filtered to polygon in generateGridCells) + for (const cell of this._cells) { + // Skip cells outside the current view + if (!viewBounds.intersects(cell.bounds)) continue; + + const sw = this._mapInstance.latLngToContainerPoint(cell.bounds.getSouthWest()); + const ne = this._mapInstance.latLngToContainerPoint(cell.bounds.getNorthEast()); + + const x = sw.x; + const y = ne.y; + const w = ne.x - sw.x; + const h = sw.y - ne.y; + + // Fill cell + ctx.globalAlpha = 0.7; + ctx.fillStyle = getColorForPayback(cell.paybackPeriod); + ctx.fillRect(x, y, w, h); + + // Stroke cell (only if large enough to see) + if (w > 3 && h > 3) { + ctx.globalAlpha = 0.5; + ctx.strokeStyle = '#222'; + ctx.lineWidth = 0.5; + ctx.strokeRect(x, y, w, h); } } - }, [polygonData]); - - // Utility: compute N x N resolution based on bbox (kilometers per side) - function computeResolutionKm(polygon: Feature, targetN = 20) { - const bbox = turf.bbox(polygon); - const [minX, minY, maxX, maxY] = bbox; - const horizontalKm = turf.distance([minX, minY], [maxX, minY], { units: 'kilometers' }); - const verticalKm = turf.distance([minX, minY], [minX, maxY], { units: 'kilometers' }); - const cellSideKm = Math.min(horizontalKm / targetN, verticalKm / targetN); - return Math.max(cellSideKm, 0.05); // guard minimal spacing (~50m) - } - // Generate square grid cells inside polygon - function regenerateGrid(polygonFC: FeatureCollection | null) { - if (!polygonFC || !polygonFC.features || polygonFC.features.length === 0) { - setGridCells({ type: 'FeatureCollection', features: [] }); - return; - } + // Draw hover highlight + if (this._hoveredCell) { + const sw = this._mapInstance.latLngToContainerPoint(this._hoveredCell.bounds.getSouthWest()); + const ne = this._mapInstance.latLngToContainerPoint(this._hoveredCell.bounds.getNorthEast()); - const polyFeature = polygonFC.features.find( - f => f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon' - ) as Feature | undefined; - if (!polyFeature) { - setGridCells({ type: 'FeatureCollection', features: [] }); - return; + ctx.globalAlpha = 1; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.strokeRect(sw.x, ne.y, ne.x - sw.x, sw.y - ne.y); } - const cellSideKm = computeResolutionKm(polyFeature, 20); - const bbox = turf.bbox(polyFeature); - const rawGrid = turf.squareGrid(bbox, cellSideKm, { units: 'kilometers' }); - - const features: Feature[] = []; - rawGrid.features.forEach((cell) => { - if (!cell || !cell.geometry) return; - if (cell.geometry.type !== 'Polygon' && cell.geometry.type !== 'MultiPolygon') return; - try { - const clipped = turf.intersect(cell as any, polyFeature as any) as Feature | null; - if (!clipped) return; - if (clipped.geometry.type !== 'Polygon' && clipped.geometry.type !== 'MultiPolygon') return; - const applicationRate = 5; - const paybackPeriod = Math.floor(Math.random() * 10) + 1; - features.push({ - ...clipped, - properties: { - ...(clipped.properties || {}), - applicationRate, - paybackPeriod, - } - }); - } catch (err) { - console.warn('Skipping grid cell due to clip error', err); + ctx.globalAlpha = 1; + } + + private _onMouseMove(e: MouseEvent): void { + if (!this._mapInstance || !this._canvas) return; + + const rect = this._canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + let foundCell: GridCell | null = null; + + for (const cell of this._cells) { + const sw = this._mapInstance.latLngToContainerPoint(cell.bounds.getSouthWest()); + const ne = this._mapInstance.latLngToContainerPoint(cell.bounds.getNorthEast()); + + if (x >= sw.x && x <= ne.x && y >= ne.y && y <= sw.y) { + foundCell = cell; + break; } - }); + } - const fc: FeatureCollection = { type: 'FeatureCollection', features }; - setGridCells(fc); + if (foundCell !== this._hoveredCell) { + this._hoveredCell = foundCell; + this._draw(); + if (this._onHover) this._onHover(foundCell, e); + } + } - const map = mapRef.current; - if (map) { - const src = map.getSource(gridCellsSourceId) as maplibregl.GeoJSONSource | undefined; - if (src) src.setData(fc as any); + private _onMouseOut(): void { + if (this._hoveredCell) { + this._hoveredCell = null; + this._draw(); + if (this._onHover) this._onHover(null, new MouseEvent('mouseout')); } } +} - // Regenerate grid only when user has submitted final coordinates - // This triggers after the farm info modal is submitted and committed to context - React.useEffect(() => { - if (!formSubmitted) { - // Clear grid if user hasn't submitted yet - setGridCells({ type: 'FeatureCollection', features: [] }); - return; +export default function PrescriptionMapViewer({ data, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { + const navigate = useNavigate(); + const { data: committedCoords, hasCoordinates, isLoading, clearCoordinateData, formSubmitted, setFormSubmitted } = useCoordinates(); + + const mapContainerRef = React.useRef(null); + const mapRef = React.useRef(null); + const gridLayerRef = React.useRef(null); + const boundaryLayerRef = React.useRef(null); + const tooltipRef = React.useRef(null); + + // Extract boundary coordinates from context or data prop + const boundaryCoords = React.useMemo(() => { + // Direct array of coords + if (Array.isArray(data) && data.length > 0 && 'lat' in data[0]) { + return data as LatLng[]; } - if (polygonData) { - regenerateGrid(polygonData); + + // From context (GeoJSON FeatureCollection) + if (committedCoords && (committedCoords as any).type === 'FeatureCollection') { + const fc = committedCoords as any; + const polyFeature = fc.features?.find( + (f: any) => f.geometry?.type === 'Polygon' + ); + + if (polyFeature?.geometry?.coordinates?.[0]) { + // GeoJSON is [lng, lat], convert to {lat, lng} + return polyFeature.geometry.coordinates[0].map((coord: [number, number]) => ({ + lat: coord[1], + lng: coord[0], + })); + } } - }, [formSubmitted, polygonData]); + + return []; + }, [data, committedCoords]); - // Sync grid cells to map source when they change + // Initialize map + React.useEffect(() => { + if (!mapContainerRef.current || mapRef.current) return; + + const map = L.map(mapContainerRef.current, { + center: [46.7, -116.96], + zoom: 14, + }); + + mapRef.current = map; + + // Add ESRI satellite imagery + L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri', + maxZoom: 20, + }).addTo(map); + + // Add ESRI labels + L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', { + maxZoom: 20, + }).addTo(map); + + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.style.cssText = ` + position: absolute; + background: white; + padding: 6px 10px; + border-radius: 4px; + font-size: 12px; + color: #111; + pointer-events: none; + z-index: 1000; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); + display: none; + `; + document.body.appendChild(tooltip); + tooltipRef.current = tooltip; + + return () => { + if (tooltipRef.current) { + document.body.removeChild(tooltipRef.current); + tooltipRef.current = null; + } + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, []); + + // Update boundary and grid when data changes React.useEffect(() => { const map = mapRef.current; - if (!map) return; - const src = map.getSource(gridCellsSourceId) as maplibregl.GeoJSONSource | undefined; - if (src) { - src.setData(gridCells as any); + if (!map || boundaryCoords.length < 3 || !formSubmitted) return; + + // Remove old layers + if (boundaryLayerRef.current) { + map.removeLayer(boundaryLayerRef.current); + } + if (gridLayerRef.current) { + map.removeLayer(gridLayerRef.current); + } + + // Draw boundary line + const latLngs = boundaryCoords.map(c => L.latLng(c.lat, c.lng)); + latLngs.push(latLngs[0]); // Close the polygon + + const boundaryLine = L.polyline(latLngs, { + color: '#FFD700', + weight: 3, + opacity: 1, + }).addTo(map); + + boundaryLayerRef.current = boundaryLine; + + // Generate grid cells (100m x 100m) + const cells = generateGridCells(boundaryCoords, 25); + console.log(`[PrescriptionMapViewer] Generated ${cells.length} grid cells`); + + // Hover handler for tooltip + const handleHover = (cell: GridCell | null, e: MouseEvent) => { + if (!tooltipRef.current) return; + + if (cell) { + tooltipRef.current.innerHTML = ` + Payback Period: ${cell.paybackPeriod}
+ Application Rate: ${cell.applicationRate} + `; + tooltipRef.current.style.display = 'block'; + tooltipRef.current.style.left = `${e.clientX + 12}px`; + tooltipRef.current.style.top = `${e.clientY + 12}px`; + } else { + tooltipRef.current.style.display = 'none'; + } + }; + + // Add grid canvas layer + const gridLayer = new GridCanvasLayer(cells, handleHover); + gridLayer.addTo(map); + gridLayerRef.current = gridLayer; + + // Fit bounds to boundary + const bounds = L.latLngBounds(latLngs); + map.fitBounds(bounds, { padding: [40, 40] }); + + }, [boundaryCoords, formSubmitted]); + + // Clear grid when form not submitted + React.useEffect(() => { + if (!formSubmitted && mapRef.current) { + if (boundaryLayerRef.current) { + mapRef.current.removeLayer(boundaryLayerRef.current); + boundaryLayerRef.current = null; + } + if (gridLayerRef.current) { + mapRef.current.removeLayer(gridLayerRef.current); + gridLayerRef.current = null; + } } - }, [gridCells]); + }, [formSubmitted]); return ( - {/* Guard: require committed coords AND submitted form */} + {/* Guard: show message if no coordinates or form not submitted */} {!isLoading && (!hasCoordinates || !formSubmitted) && ( navigate('/')} - sx={{ backgroundColor: COLORS.indigo, '&:hover': { backgroundColor: '#7a81ff' } }} + sx={{ + backgroundColor: COLORS.indigo, + '&:hover': { backgroundColor: '#7a81ff' }, + }} > Go to Input Page @@ -309,13 +464,11 @@ export default function PrescriptionMapViewer({ data = samplePrescriptionData, h - {!data && ( - - No prescription data available. Upload a GeoJSON from the backend to preview application zones. - - )} + + Displaying farm boundary and grid cells with estimated payback periods. Hover over cells for details. + - +
diff --git a/frontend/src/contexts/CoordinateContext.tsx b/frontend/src/contexts/CoordinateContext.tsx index bcd8de7..0d4831b 100644 --- a/frontend/src/contexts/CoordinateContext.tsx +++ b/frontend/src/contexts/CoordinateContext.tsx @@ -6,6 +6,8 @@ const STORAGE_KEY = 'charai_coordinate_data'; // committed/approved coordinates const PENDING_STORAGE_KEY = 'charai_coordinate_pending'; // latest user input (manual or upload) before submit const SUBMIT_STORAGE_KEY = 'charai_farm_submitted'; +type LatLng = { lat: number; lng: number }; + interface CoordinateContextType { data: FeatureCollection | null; pendingData: FeatureCollection | null; @@ -13,7 +15,7 @@ interface CoordinateContextType { hasCoordinates: boolean; hasPendingCoordinates: boolean; formSubmitted: boolean; - setCoordinateData: (data: FeatureCollection) => void; // sets pending draft coords + setCoordinateData: (data: FeatureCollection | LatLng[]) => void; // accepts GeoJSON or raw lat/lng array commitPendingCoordinates: () => void; // promote pending -> committed and mark submitted setFormSubmitted: (submitted: boolean) => void; clearCoordinateData: () => void; @@ -27,6 +29,30 @@ export function CoordinateProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); const [formSubmitted, setFormSubmittedState] = useState(false); + // Helper: convert [{lat,lng}, ...] to a closed GeoJSON FeatureCollection with temporary default properties + const coordsToFeatureCollection = (coords: LatLng[]): FeatureCollection | null => { + if (!coords || coords.length < 3) return null; + const ring: [number, number][] = coords.map(p => [p.lng, p.lat]); + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + ring.push([...first]); + } + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { applicationRate: 5, paybackPeriod: 3, type: 'boundary' }, + geometry: { + type: 'Polygon', + coordinates: [ring], + }, + }, + ], + }; + }; + // On mount, load coordinate data from localStorage useEffect(() => { try { @@ -51,10 +77,17 @@ export function CoordinateProvider({ children }: { children: ReactNode }) { } }, []); - const setCoordinateData = (newData: FeatureCollection) => { - setPendingData(newData); + const setCoordinateData = (newData: FeatureCollection | LatLng[]) => { + let fc: FeatureCollection | null = null; + if (Array.isArray(newData)) { + fc = coordsToFeatureCollection(newData); + } else { + fc = newData; + } + if (!fc) return; + setPendingData(fc); try { - localStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(newData)); + localStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(fc)); } catch (err) { console.error('Failed to save pending coordinates to localStorage:', err); } @@ -62,10 +95,25 @@ export function CoordinateProvider({ children }: { children: ReactNode }) { const commitPendingCoordinates = () => { if (!pendingData) return; - setData(pendingData); + // Ensure temporary default properties exist for hover purposes + const committed: FeatureCollection = { + type: 'FeatureCollection', + features: pendingData.features.map(f => ({ + ...f, + properties: { + applicationRate: f.properties?.applicationRate ?? 5, + paybackPeriod: f.properties?.paybackPeriod ?? 3, + ...f.properties, + }, + })), + }; + + console.log('Committed Coordinates GeoJSON:', JSON.stringify(committed, null, 2)); + + setData(committed); setFormSubmittedState(true); try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(pendingData)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(committed)); localStorage.setItem(SUBMIT_STORAGE_KEY, 'true'); } catch (err) { console.error('Failed to commit coordinates to localStorage:', err); diff --git a/frontend/src/samplePrescriptionData.ts b/frontend/src/samplePrescriptionData.ts deleted file mode 100644 index 3e51ec5..0000000 --- a/frontend/src/samplePrescriptionData.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { FeatureCollection, Feature, Polygon } from 'geojson'; - -// Point-in-polygon (ray casting) for simple polygon rings -function pointInPolygon(point: [number, number], ring: [number, number][]): boolean { - let inside = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const xi = ring[i][0], yi = ring[i][1]; - const xj = ring[j][0], yj = ring[j][1]; - const intersect = ((yi > point[1]) !== (yj > point[1])) && - (point[0] < (xj - xi) * (point[1] - yi) / (yj - yi + 0.0000001) + xi); - if (intersect) inside = !inside; - } - return inside; -} - -// Helper to build a square polygon given bottom-left corner and size in degrees -function square(lng: number, lat: number, sizeDeg = 0.0007): Feature> { - return { - type: 'Feature', - properties: {}, - geometry: { - type: 'Polygon', - coordinates: [[ - [lng, lat], - [lng + sizeDeg, lat], - [lng + sizeDeg, lat + sizeDeg], - [lng, lat + sizeDeg], - [lng, lat] - ]] - } - }; -} - -// Convex farm boundary (pentagon) near Boise, ID -const boundary: Feature = { - type: 'Feature', - properties: { type: 'boundary' }, - geometry: { - type: 'Polygon', - coordinates: [[ - [-116.2425, 43.6025], - [-116.2395, 43.6032], - [-116.2379, 43.6006], - [-116.2393, 43.5984], - [-116.2419, 43.5981], - [-116.2425, 43.6025] - ]] - } -}; - -// Generate a small pixelated grid inside boundary bbox -const gridOrigin = { lng: -116.2418, lat: 43.5986 }; -const cellSize = 0.0007; // ~78m -const rows = 4; -const cols = 4; - -const grid: Feature>[] = []; -for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const lng = gridOrigin.lng + c * cellSize; - const lat = gridOrigin.lat + r * cellSize; - const f = square(lng, lat, cellSize); - // Uniform application rate for all squares; vary paybackPeriod numerically - const paybackOptions = [4, 8, 12, 18, 28]; // months - const idx = (r + c) % paybackOptions.length; - const center: [number, number] = [lng + cellSize / 2, lat + cellSize / 2]; - f.properties = { - applicationRate: 120, // same rate across field - paybackPeriod: paybackOptions[idx] - }; - // Include only squares whose center falls within the boundary (approximate clipping) - const ring = boundary.geometry.coordinates[0] as [number, number][]; - if (pointInPolygon(center, ring)) { - grid.push(f); - } - } -} - -export const samplePrescriptionData: FeatureCollection = { - type: 'FeatureCollection', - features: [boundary, ...grid] -}; From 2652e4fa26ebc3fa2ef5a7e4cf307a215ba72a0d Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 21:28:39 -0700 Subject: [PATCH 24/25] feat: group components by feature, make styling logic more readable / reuseable --- docs/01_Problem_Definition/Requirements.md | 20 +- .../Schedule/Schedule.md | 2 +- frontend/README.md | 24 +- frontend/src/components/AppRoutes.tsx | 9 +- frontend/src/components/BiocharMap.tsx | 41 -- frontend/src/components/FieldsList.tsx | 178 ----- frontend/src/components/Header.tsx | 16 +- .../src/components/InteractiveFarmMap.tsx | 1 - .../src/components/PrescriptionMapViewer.tsx | 1 - .../PrescriptionMapViewer.tsx | 18 +- .../PrescriptionMapViewer/bounds.ts | 1 - .../PrescriptionMapViewer/priorityStyle.ts | 4 - .../auth}/ProtectedRoute.tsx | 4 +- .../auth}/PublicRoute.tsx | 2 +- frontend/src/features/auth/index.ts | 2 + .../farm}/BudgetSettings.tsx | 2 +- .../farm}/CoordinateFileUpload.tsx | 15 +- .../farm}/FarmBiocharForm.tsx | 44 +- frontend/src/features/farm/FieldsList.tsx | 163 +++++ .../farm}/FileUploadSection.tsx | 8 +- .../farm}/ManualCoordinateUpload.tsx | 180 ++--- .../farm}/SubmitSection.tsx | 6 +- .../farm}/YieldFileUpload.tsx | 12 +- frontend/src/features/farm/index.ts | 9 + frontend/src/features/index.ts | 4 + .../map}/InteractiveFarmMap.tsx | 64 +- frontend/src/features/map/index.ts | 1 + .../prescriptions/PrescriptionMapViewer.tsx | 647 ++++++++++++++++++ frontend/src/features/prescriptions/index.ts | 1 + frontend/src/pages/HomePage.tsx | 4 +- frontend/src/pages/LandingPage.tsx | 7 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/PrescriptionsPage.tsx | 2 +- frontend/src/pages/SignupPage.tsx | 2 +- frontend/src/styles/colors.ts | 46 +- frontend/src/types/maplibre/priorityStyle.ts | 24 +- 36 files changed, 1135 insertions(+), 431 deletions(-) delete mode 100644 frontend/src/components/BiocharMap.tsx delete mode 100644 frontend/src/components/FieldsList.tsx delete mode 100644 frontend/src/components/InteractiveFarmMap.tsx delete mode 100644 frontend/src/components/PrescriptionMapViewer.tsx delete mode 100644 frontend/src/components/PrescriptionMapViewer/bounds.ts delete mode 100644 frontend/src/components/PrescriptionMapViewer/priorityStyle.ts rename frontend/src/{components => features/auth}/ProtectedRoute.tsx (81%) rename frontend/src/{components => features/auth}/PublicRoute.tsx (93%) create mode 100644 frontend/src/features/auth/index.ts rename frontend/src/{components => features/farm}/BudgetSettings.tsx (97%) rename frontend/src/{components => features/farm}/CoordinateFileUpload.tsx (89%) rename frontend/src/{components => features/farm}/FarmBiocharForm.tsx (71%) create mode 100644 frontend/src/features/farm/FieldsList.tsx rename frontend/src/{components => features/farm}/FileUploadSection.tsx (90%) rename frontend/src/{components => features/farm}/ManualCoordinateUpload.tsx (59%) rename frontend/src/{components => features/farm}/SubmitSection.tsx (90%) rename frontend/src/{components => features/farm}/YieldFileUpload.tsx (92%) create mode 100644 frontend/src/features/farm/index.ts create mode 100644 frontend/src/features/index.ts rename frontend/src/{components/InteractiveFarmMap => features/map}/InteractiveFarmMap.tsx (62%) create mode 100644 frontend/src/features/map/index.ts create mode 100644 frontend/src/features/prescriptions/PrescriptionMapViewer.tsx create mode 100644 frontend/src/features/prescriptions/index.ts diff --git a/docs/01_Problem_Definition/Requirements.md b/docs/01_Problem_Definition/Requirements.md index eb3ce23..fca3d41 100644 --- a/docs/01_Problem_Definition/Requirements.md +++ b/docs/01_Problem_Definition/Requirements.md @@ -1,8 +1,8 @@ -# Project Requirements — Biochar Placement Optimization Tool +# Project Requirements - Biochar Placement Optimization Tool ## Table of Contents -- [Project Requirements — Biochar Placement Optimization Tool](#project-requirements--biochar-placement-optimization-tool) +- [Project Requirements - Biochar Placement Optimization Tool](#project-requirements--biochar-placement-optimization-tool) - [Table of Contents](#table-of-contents) - [Functional Requirements](#functional-requirements) - [User-Facing Input/Output](#user-facing-inputoutput) @@ -65,11 +65,11 @@ The total budget of the product shall not exceed $1000. The following schedule outline shall be followed: -- Approval of Requirements — Sept. 30, 2025 -- Concept Design Review — Nov. 30, 2025 -- EPO of long lead parts — Dec. 8, 2025 -- Detailed Design Review — Feb. 9, 2026 -- ER of drawing package — Mar. 2, 2026 -- Complete Prototype Build — Apr. 5, 2026 -- UI Design EXPO — Apr. 26, 2026 -- Final Report / Drawings — May 4, 2026 \ No newline at end of file +- Approval of Requirements - Sept. 30, 2025 +- Concept Design Review - Nov. 30, 2025 +- EPO of long lead parts - Dec. 8, 2025 +- Detailed Design Review - Feb. 9, 2026 +- ER of drawing package - Mar. 2, 2026 +- Complete Prototype Build - Apr. 5, 2026 +- UI Design EXPO - Apr. 26, 2026 +- Final Report / Drawings - May 4, 2026 \ No newline at end of file diff --git a/docs/03_Project_Management/Schedule/Schedule.md b/docs/03_Project_Management/Schedule/Schedule.md index df303de..c2ea51f 100644 --- a/docs/03_Project_Management/Schedule/Schedule.md +++ b/docs/03_Project_Management/Schedule/Schedule.md @@ -4,7 +4,7 @@ The project schedule outlines the planned timeline and major milestones for Char The schedule provides a high-level view of dependencies between tasks, showing how progress in one area (such as dataset preparation) enables the next (such as model development). By visualizing these relationships, the schedule helps identify potential bottlenecks early and ensures efficient coordination between contributors. -The Gantt chart accompanying this document serves as the main visual reference for the project’s timeline. It includes major milestones—such as Snapshot Days—to track deliverable progress and measure readiness for deployment. +The Gantt chart accompanying this document serves as the main visual reference for the project's timeline. It includes major milestones-such as Snapshot Days-to track deliverable progress and measure readiness for deployment. In summary, the schedule acts as both a **planning tool** and a **communication aid**, helping the team maintain focus, manage dependencies, and make informed adjustments as the project evolves. diff --git a/frontend/README.md b/frontend/README.md index c178e59..be2ea38 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -110,14 +110,14 @@ The frontend integrates with Django REST Framework using **TokenAuthentication** ### Key Files -- `src/services/authService.ts` — API calls and token management -- `src/contexts/AuthContext.tsx` — Auth state and methods -- `src/types/auth.ts` — TypeScript types for auth -- `src/components/ProtectedRoute.tsx` — Guards authenticated pages -- `src/components/PublicRoute.tsx` — Prevents logged-in users from seeing login/signup -- `src/pages/LoginPage.tsx` — Login form -- `src/pages/SignupPage.tsx` — Registration form -- `src/pages/HomePage.tsx` — Protected home page (shown only to authenticated users) +- `src/services/authService.ts` - API calls and token management +- `src/contexts/AuthContext.tsx` - Auth state and methods +- `src/types/auth.ts` - TypeScript types for auth +- `src/components/ProtectedRoute.tsx` - Guards authenticated pages +- `src/components/PublicRoute.tsx` - Prevents logged-in users from seeing login/signup +- `src/pages/LoginPage.tsx` - Login form +- `src/pages/SignupPage.tsx` - Registration form +- `src/pages/HomePage.tsx` - Protected home page (shown only to authenticated users) --- @@ -172,10 +172,10 @@ Commit both `package.json` and the lock file to version control. - **Package manager**: `npm` - **Project root**: `src/` - **Main commands**: - - `npm run dev` — start development server - - `npm run build` — build for production and type check - - `npm run preview` — preview production build - - `npm run lint` — lint codebase + - `npm run dev` - start development server + - `npm run build` - build for production and type check + - `npm run preview` - preview production build + - `npm run lint` - lint codebase - **Configuration**: - TypeScript: `tsconfig.json` - Vite: `vite.config.ts` diff --git a/frontend/src/components/AppRoutes.tsx b/frontend/src/components/AppRoutes.tsx index e8f3de9..b3a42b4 100644 --- a/frontend/src/components/AppRoutes.tsx +++ b/frontend/src/components/AppRoutes.tsx @@ -3,22 +3,21 @@ import App from '../pages/LandingPage'; import LoginPage from '../pages/LoginPage'; import SignupPage from '../pages/SignupPage'; import HomePage from '../pages/HomePage'; -import { ProtectedRoute } from './ProtectedRoute'; -import { PublicRoute } from './PublicRoute'; +import { ProtectedRoute, PublicRoute } from '../features/auth'; import PrescriptionsPage from '../pages/PrescriptionsPage'; export default function AppRoutes() { return ( - {/* Root "/" route - shows HomePage for authenticated, App.tsx for unauthenticated */} + {/* Root "/" route - shows HomePage for authenticated, LandingPage for unauthenticated */} } fallback={} />} /> {/* Auth pages - public, redirects authenticated users to home */} } />} /> } />} /> - {/* Temporary public pages for testing */} - } />} /> + {/* Prescriptions page - requires authentication */} + } />} /> {/* Catch-all for unmatched routes */} } fallback={} />} /> diff --git a/frontend/src/components/BiocharMap.tsx b/frontend/src/components/BiocharMap.tsx deleted file mode 100644 index 9ea8756..0000000 --- a/frontend/src/components/BiocharMap.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { MapContainer, TileLayer, Polygon } from "react-leaflet"; -import "leaflet/dist/leaflet.css"; -import type { LatLngTuple } from "leaflet"; - -const BiocharMap = () => { - // Mock field polygon (replace with parsed CSV or backend) - const fieldCoords: LatLngTuple[] = [ - [43.612, -116.391], - [43.613, -116.391], - [43.613, -116.389], - [43.612, -116.389] - ]; - - return ( -
- - {/* ESRI Satellite Imagery */} - - - {/* Field boundary polygon */} - - -
- ); -}; - -export default BiocharMap; diff --git a/frontend/src/components/FieldsList.tsx b/frontend/src/components/FieldsList.tsx deleted file mode 100644 index 0349d5b..0000000 --- a/frontend/src/components/FieldsList.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { Box, Button, Stack, Typography, IconButton, Accordion, AccordionSummary, AccordionDetails, TextField, Select, MenuItem, InputAdornment } from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import AddIcon from '@mui/icons-material/Add'; -import DeleteIcon from '@mui/icons-material/Delete'; -import { COLORS } from '../styles/colors'; - -export interface FieldEntry { - id: string; - cropType: string; - customCrop?: string; - price: number | ''; - unit: 'ton' | 'kg' | 'bushel'; -} - -interface FieldsListProps { - fields: FieldEntry[]; - onAddField: () => void; - onRemoveField: (id: string) => void; - onUpdateField: (id: string, patch: Partial) => void; -} - -export default function FieldsList({ fields, onAddField, onRemoveField, onUpdateField }: FieldsListProps) { - return ( - - - - - Your Fields ({fields.length}) - - - Add all fields where you plan to apply biochar. For each field, specify the crop type and current selling price to help calculate potential revenue impacts. - - - - - - - {fields.map((f, idx) => ( - - }> - - Field {idx + 1} — {f.cropType === 'Other' ? (f.customCrop || 'Other') : f.cropType} - - {f.price && ( - - ${f.price}/{f.unit} - - )} - - - - {/* Crop Type */} - - Crop type - - Choose the primary crop grown in this field. - - - - {f.cropType === 'Other' && ( - onUpdateField(f.id, { customCrop: e.target.value })} - placeholder="e.g., Alfalfa, Oats" - sx={{ - '& .MuiOutlinedInput-root': { - color: COLORS.whiteHigh, - '& fieldset': { borderColor: COLORS.whiteLow }, - '&:hover fieldset': { borderColor: COLORS.indigo }, - }, - '& .MuiInputLabel-root': { color: `${COLORS.whiteMedium} !important` }, - }} - /> - )} - - - {/* Selling Price */} - - Selling price - - Current market price per unit - - - onUpdateField(f.id, { price: e.target.value === '' ? '' : Number(e.target.value) })} - sx={{ - minWidth: 120, - '& .MuiOutlinedInput-root': { - color: COLORS.whiteHigh, - '& fieldset': { borderColor: COLORS.whiteLow }, - '&:hover fieldset': { borderColor: COLORS.indigo }, - }, - '& .MuiInputLabel-root': { - color: `${COLORS.whiteMedium} !important`, - }, - }} - slotProps={{ - input: { - startAdornment: ( - - $ - - ) - }, - }} - /> - - - - - {/* Remove Button */} - - { - e.stopPropagation(); - onRemoveField(f.id); - }} - sx={{ color: 'error.light' }} - aria-label={`Remove field ${idx + 1}`} - > - - - - - - - ))} - - - ); -} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index c291d2d..1b2ad88 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -15,7 +15,7 @@ const Header = () => { }; return ( - + { {isAuthenticated ? ( <> + - )} diff --git a/frontend/src/components/InteractiveFarmMap.tsx b/frontend/src/components/InteractiveFarmMap.tsx deleted file mode 100644 index 0cdfd29..0000000 --- a/frontend/src/components/InteractiveFarmMap.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './InteractiveFarmMap/InteractiveFarmMap'; \ No newline at end of file diff --git a/frontend/src/components/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer.tsx deleted file mode 100644 index 60513ae..0000000 --- a/frontend/src/components/PrescriptionMapViewer.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PrescriptionMapViewer/PrescriptionMapViewer'; \ No newline at end of file diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx index 4dbe664..0d82197 100644 --- a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx +++ b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx @@ -22,11 +22,11 @@ interface PrescriptionMapViewerProps { // Color scale for payback period (1-10) const getColorForPayback = (paybackPeriod: number): string => { - if (paybackPeriod <= 2) return '#1a9641'; // green - if (paybackPeriod <= 4) return '#a6d96a'; // light green - if (paybackPeriod <= 6) return '#f9d423'; // yellow - if (paybackPeriod <= 8) return '#f58634'; // orange - return '#d7191c'; // red + if (paybackPeriod <= 2) return COLORS.dataGreen; // green + if (paybackPeriod <= 4) return COLORS.dataLightGreen; // light green + if (paybackPeriod <= 6) return COLORS.dataYellow; // yellow + if (paybackPeriod <= 8) return COLORS.dataOrange; // orange + return COLORS.dataRed; // red }; // Point-in-polygon test using ray casting algorithm @@ -197,7 +197,7 @@ class GridCanvasLayer extends L.Layer { // Stroke cell (only if large enough to see) if (w > 3 && h > 3) { ctx.globalAlpha = 0.5; - ctx.strokeStyle = '#222'; + ctx.strokeStyle = COLORS.strokeDark; ctx.lineWidth = 0.5; ctx.strokeRect(x, y, w, h); } @@ -209,7 +209,7 @@ class GridCanvasLayer extends L.Layer { const ne = this._mapInstance.latLngToContainerPoint(this._hoveredCell.bounds.getNorthEast()); ctx.globalAlpha = 1; - ctx.strokeStyle = '#fff'; + ctx.strokeStyle = COLORS.whiteFull; ctx.lineWidth = 2; ctx.strokeRect(sw.x, ne.y, ne.x - sw.x, sw.y - ne.y); } @@ -357,7 +357,7 @@ export default function PrescriptionMapViewer({ data, height = '400px', width = latLngs.push(latLngs[0]); // Close the polygon const boundaryLine = L.polyline(latLngs, { - color: '#FFD700', + color: COLORS.gold, weight: 3, opacity: 1, }).addTo(map); @@ -437,7 +437,7 @@ export default function PrescriptionMapViewer({ data, height = '400px', width = onClick={() => navigate('/')} sx={{ backgroundColor: COLORS.indigo, - '&:hover': { backgroundColor: '#7a81ff' }, + '&:hover': { backgroundColor: COLORS.indigoHover }, }} > Go to Input Page diff --git a/frontend/src/components/PrescriptionMapViewer/bounds.ts b/frontend/src/components/PrescriptionMapViewer/bounds.ts deleted file mode 100644 index e495428..0000000 --- a/frontend/src/components/PrescriptionMapViewer/bounds.ts +++ /dev/null @@ -1 +0,0 @@ -export { computeBoundsFromGeoJSON } from '../../types/maplibre/bounds'; diff --git a/frontend/src/components/PrescriptionMapViewer/priorityStyle.ts b/frontend/src/components/PrescriptionMapViewer/priorityStyle.ts deleted file mode 100644 index 38e82ef..0000000 --- a/frontend/src/components/PrescriptionMapViewer/priorityStyle.ts +++ /dev/null @@ -1,4 +0,0 @@ -// MapLibre GL paint expression to color polygons by priority. -// Supports `properties.priority` with fallback to `properties.priorityRange`. -// Fallback color is #90caf9. -export { priorityFillColorExpression } from '../../types/maplibre/priorityStyle'; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/features/auth/ProtectedRoute.tsx similarity index 81% rename from frontend/src/components/ProtectedRoute.tsx rename to frontend/src/features/auth/ProtectedRoute.tsx index b2700ad..f855a9e 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/features/auth/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import { Box, CircularProgress } from '@mui/material'; -import { useAuth } from '../contexts/AuthContext'; +import { useAuth } from '../../contexts/AuthContext'; interface ProtectedRouteProps { element: React.ReactNode; @@ -33,8 +33,6 @@ export function ProtectedRoute({ element, fallback }: ProtectedRouteProps) { if (fallback) { return <>{fallback}; } - // If no fallback provided, redirect would be handled by PublicRoute wrapping - // But since we're in ProtectedRoute, we show nothing (or could redirect) return null; } diff --git a/frontend/src/components/PublicRoute.tsx b/frontend/src/features/auth/PublicRoute.tsx similarity index 93% rename from frontend/src/components/PublicRoute.tsx rename to frontend/src/features/auth/PublicRoute.tsx index 8015492..4fc1a5c 100644 --- a/frontend/src/components/PublicRoute.tsx +++ b/frontend/src/features/auth/PublicRoute.tsx @@ -1,6 +1,6 @@ import { Navigate } from 'react-router'; import { Box, CircularProgress } from '@mui/material'; -import { useAuth } from '../contexts/AuthContext'; +import { useAuth } from '../../contexts/AuthContext'; interface PublicRouteProps { element: React.ReactNode; diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts new file mode 100644 index 0000000..e81566e --- /dev/null +++ b/frontend/src/features/auth/index.ts @@ -0,0 +1,2 @@ +export { ProtectedRoute } from './ProtectedRoute'; +export { PublicRoute } from './PublicRoute'; diff --git a/frontend/src/components/BudgetSettings.tsx b/frontend/src/features/farm/BudgetSettings.tsx similarity index 97% rename from frontend/src/components/BudgetSettings.tsx rename to frontend/src/features/farm/BudgetSettings.tsx index db5b913..12059c3 100644 --- a/frontend/src/components/BudgetSettings.tsx +++ b/frontend/src/features/farm/BudgetSettings.tsx @@ -1,5 +1,5 @@ import { Box, TextField, Typography, InputAdornment } from '@mui/material'; -import { COLORS } from '../styles/colors'; +import { COLORS } from '../../styles/colors'; interface BudgetSettingsProps { globalMax: number | ''; diff --git a/frontend/src/components/CoordinateFileUpload.tsx b/frontend/src/features/farm/CoordinateFileUpload.tsx similarity index 89% rename from frontend/src/components/CoordinateFileUpload.tsx rename to frontend/src/features/farm/CoordinateFileUpload.tsx index ef7a558..0c6c83c 100644 --- a/frontend/src/components/CoordinateFileUpload.tsx +++ b/frontend/src/features/farm/CoordinateFileUpload.tsx @@ -2,10 +2,9 @@ import { Box, Button, IconButton, Stack, Typography, CircularProgress } from "@m import { styled } from "@mui/material/styles"; import React from "react"; import CloseIcon from "@mui/icons-material/Close"; -import { uploadCoordinateFile } from "../services/fileUploadService"; -import { parseFileToGeoJSON } from "../services/coordinateService"; -import { useCoordinates } from "../contexts/CoordinateContext"; -import { COLORS } from "../styles/colors"; +import { parseFileToGeoJSON } from "../../services/coordinateService"; +import { useCoordinates } from "../../contexts/CoordinateContext"; +import { COLORS } from "../../styles/colors"; const VisuallyHiddenInput = styled('input')({ clip: 'rect(0 0 0 0)', @@ -69,7 +68,7 @@ export default function CoordinateFileUpload(props: { onSelect?: (file: File | n textTransform: 'none', fontSize: '1rem', backgroundColor: COLORS.indigo, - '&:hover': { backgroundColor: '#7a81ff' } + '&:hover': { backgroundColor: COLORS.indigoHover } }} > Choose file @@ -86,7 +85,7 @@ export default function CoordinateFileUpload(props: { onSelect?: (file: File | n sx={{ alignItems: 'center', padding: 1.5, - backgroundColor: 'rgba(100, 108, 255, 0.1)', + backgroundColor: COLORS.indigoLight, border: `1px solid ${COLORS.indigo}`, borderRadius: 1, minWidth: 300, @@ -122,7 +121,7 @@ export default function CoordinateFileUpload(props: { onSelect?: (file: File | n textTransform: 'none', fontSize: '1rem', backgroundColor: COLORS.indigo, - '&:hover': { backgroundColor: '#7a81ff' } + '&:hover': { backgroundColor: COLORS.indigoHover } }} > {isLoading ? : null} @@ -131,4 +130,4 @@ export default function CoordinateFileUpload(props: { onSelect?: (file: File | n )} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/FarmBiocharForm.tsx b/frontend/src/features/farm/FarmBiocharForm.tsx similarity index 71% rename from frontend/src/components/FarmBiocharForm.tsx rename to frontend/src/features/farm/FarmBiocharForm.tsx index 14108ad..80ba3d7 100644 --- a/frontend/src/components/FarmBiocharForm.tsx +++ b/frontend/src/features/farm/FarmBiocharForm.tsx @@ -9,16 +9,16 @@ import { DialogContent, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; -import { COLORS } from '../styles/colors'; +import { COLORS } from '../../styles/colors'; import BudgetSettings from './BudgetSettings'; import FieldsList from './FieldsList'; import type { FieldEntry } from './FieldsList'; import FileUploadSection from './FileUploadSection'; import SubmitSection from './SubmitSection'; -import { useCoordinates } from '../contexts/CoordinateContext'; +import { useCoordinates } from '../../contexts/CoordinateContext'; const DEFAULT_FIELD = (): FieldEntry => ({ - id: String(Date.now()) + Math.random().toString(36).slice(2, 9), + id: 'main-field', cropType: 'Wheat', customCrop: '', price: '', @@ -26,17 +26,13 @@ const DEFAULT_FIELD = (): FieldEntry => ({ }); export default function FarmBiocharForm() { - const { hasCoordinates, hasPendingCoordinates, formSubmitted, setFormSubmitted, commitPendingCoordinates } = useCoordinates(); - const [fields, setFields] = React.useState([DEFAULT_FIELD()]); + const { hasCoordinates, hasPendingCoordinates, setFormSubmitted, commitPendingCoordinates } = useCoordinates(); + const [field, setField] = React.useState(DEFAULT_FIELD()); const [globalMax, setGlobalMax] = React.useState(''); const [isModalOpen, setIsModalOpen] = React.useState(false); - const addField = () => setFields(prev => [...prev, DEFAULT_FIELD()]); - - const removeField = (id: string) => setFields(prev => prev.filter(f => f.id !== id)); - - const updateField = (id: string, patch: Partial) => { - setFields(prev => prev.map(f => (f.id === id ? { ...f, ...patch } : f))); + const updateField = (patch: Partial) => { + setField(prev => ({ ...prev, ...patch })); }; // file/manual coordinate state (ready when uploaded OR drawn OR already present) @@ -50,10 +46,6 @@ export default function FarmBiocharForm() { }, [hasCoordinates, hasPendingCoordinates]); const handleCoordSelect = (file: File | null) => { - // If a file is selected, mark coordinates as uploaded/available so the form can be submitted. - // This covers the common case where the user selects/uploads a file and we want the - // Submit button to enable immediately. If the parent uploader also calls - // `onUploadComplete`, that will also set this to true. setCoordUploaded(!!file); }; @@ -73,6 +65,7 @@ export default function FarmBiocharForm() { const closeModal = () => setIsModalOpen(false); const coordsReady = coordUploaded || hasPendingCoordinates || hasCoordinates; + const isPriceValid = field.price !== '' && field.price > 0; const FormContent = () => ( @@ -82,18 +75,16 @@ export default function FarmBiocharForm() { Farm Configuration - Define your fields, crops, and biochar budget allocation. Upload coordinate data to define your farm boundaries. + Configure your field's crop and selling price, set your biochar budget, and upload boundary coordinates to calculate optimal application rates. {/* Budget Settings */} - {/* Fields List */} + {/* Field Configuration */} @@ -107,11 +98,14 @@ export default function FarmBiocharForm() { {/* Submit Section */} { - const payload = { globalMax, fields }; + if (!isPriceValid) { + alert('Please enter a valid crop selling price.'); + return; + } + const payload = { globalMax, field }; console.log('Submit payload', payload); - alert('Form submitted! Check console for payload details.'); commitPendingCoordinates(); setFormSubmitted(true); closeModal(); @@ -126,7 +120,7 @@ export default function FarmBiocharForm() { @@ -139,7 +133,7 @@ export default function FarmBiocharForm() { fullWidth PaperProps={{ sx: { - backgroundColor: '#000000', + backgroundColor: COLORS.blackFull, backgroundImage: 'none', color: COLORS.whiteHigh, maxHeight: '90vh', diff --git a/frontend/src/features/farm/FieldsList.tsx b/frontend/src/features/farm/FieldsList.tsx new file mode 100644 index 0000000..03d0df7 --- /dev/null +++ b/frontend/src/features/farm/FieldsList.tsx @@ -0,0 +1,163 @@ +import { Box, Stack, Typography, Accordion, AccordionSummary, AccordionDetails, TextField, Select, MenuItem, InputAdornment } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { COLORS } from '../../styles/colors'; + +export interface FieldEntry { + id: string; + cropType: string; + customCrop?: string; + price: number | ''; + unit: 'ton' | 'kg' | 'bushel'; +} + +interface FieldsListProps { + field: FieldEntry; + onUpdateField: (patch: Partial) => void; +} + +export default function FieldsList({ field, onUpdateField }: FieldsListProps) { + const isPriceValid = field.price !== '' && field.price > 0; + + return ( + + + + Field Configuration + + + Configure your field's crop type and current selling price to calculate biochar ROI. + + + + + } + sx={{ '& .MuiAccordionSummary-content': { alignItems: 'center' } }} + > + + {field.cropType === 'Other' ? (field.customCrop || 'Other') : field.cropType} + + {field.price && ( + + ${field.price}/{field.unit} + + )} + {!isPriceValid && ( + + Price required + + )} + + + + {/* Crop Type */} + + Crop type + + Choose the primary crop grown in your field. + + + + {field.cropType === 'Other' && ( + onUpdateField({ customCrop: e.target.value })} + placeholder="e.g., Alfalfa, Oats" + sx={{ + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: COLORS.whiteLow }, + '&:hover fieldset': { borderColor: COLORS.indigo }, + }, + '& .MuiInputLabel-root': { color: `${COLORS.whiteMedium} !important` }, + }} + /> + )} + + + {/* Selling Price */} + + + Selling price * + + + Current market price per unit (required) + + + onUpdateField({ price: e.target.value === '' ? '' : Number(e.target.value) })} + sx={{ + minWidth: 120, + '& .MuiOutlinedInput-root': { + color: COLORS.whiteHigh, + '& fieldset': { borderColor: !isPriceValid ? COLORS.error : COLORS.whiteLow }, + '&:hover fieldset': { borderColor: !isPriceValid ? COLORS.error : COLORS.indigo }, + }, + '& .MuiInputLabel-root': { + color: `${COLORS.whiteMedium} !important`, + }, + }} + slotProps={{ + input: { + startAdornment: ( + + $ + + ) + }, + }} + /> + + + + + + + + ); +} diff --git a/frontend/src/components/FileUploadSection.tsx b/frontend/src/features/farm/FileUploadSection.tsx similarity index 90% rename from frontend/src/components/FileUploadSection.tsx rename to frontend/src/features/farm/FileUploadSection.tsx index 12ae246..1ae6c30 100644 --- a/frontend/src/components/FileUploadSection.tsx +++ b/frontend/src/features/farm/FileUploadSection.tsx @@ -1,5 +1,5 @@ import { Box, Stack, Typography } from '@mui/material'; -import { COLORS } from '../styles/colors'; +import { COLORS } from '../../styles/colors'; import CoordinateFileUpload from './CoordinateFileUpload'; import YieldFileUpload from './YieldFileUpload'; import ManualCoordinateUpload from './ManualCoordinateUpload'; @@ -18,7 +18,7 @@ export default function FileUploadSection({ onYieldUploaded, }: FileUploadSectionProps) { return ( - + Upload Farm Data @@ -28,11 +28,11 @@ export default function FileUploadSection({ {/* Coordinate File Box */} - + Coordinate file - Required + Required diff --git a/frontend/src/components/ManualCoordinateUpload.tsx b/frontend/src/features/farm/ManualCoordinateUpload.tsx similarity index 59% rename from frontend/src/components/ManualCoordinateUpload.tsx rename to frontend/src/features/farm/ManualCoordinateUpload.tsx index 1cffaa1..ff2211f 100644 --- a/frontend/src/components/ManualCoordinateUpload.tsx +++ b/frontend/src/features/farm/ManualCoordinateUpload.tsx @@ -1,47 +1,53 @@ import { Box, Button, Dialog, DialogContent, DialogTitle, IconButton, Typography, Divider, Paper, Stack } from "@mui/material"; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import EditIcon from '@mui/icons-material/Edit'; -import { COLORS } from "../styles/colors"; +import { COLORS } from "../../styles/colors"; import React from "react"; -import InteractiveFarmMap from "./InteractiveFarmMap"; +import { InteractiveFarmMap } from "../map"; import { type LatLngLiteral } from "leaflet"; -import { useCoordinates } from "../contexts/CoordinateContext"; +import { useCoordinates } from "../../contexts/CoordinateContext"; import type { FeatureCollection, Feature, Polygon } from 'geojson'; export default function ManualCoordinateUpload() { - const { data, setCoordinateData } = useCoordinates(); + const { data, pendingData, setCoordinateData } = useCoordinates(); const [isModalOpen, setIsModalOpen] = React.useState(false); const [markers, setMarkers] = React.useState([]); // On mount or when modal opens with existing data, load markers from context + // Check pendingData first, fall back to data (committed coords) React.useEffect(() => { - if (isModalOpen && data) { - // Extract markers from the boundary polygon (first feature with type Polygon) - const boundaryFeature = data.features.find(f => f.geometry.type === 'Polygon') as Feature | undefined; - if (boundaryFeature?.geometry.type === 'Polygon') { - const coords = boundaryFeature.geometry.coordinates[0]; - // Remove last coord (which closes the polygon) - const polygonMarkers: LatLngLiteral[] = coords.slice(0, -1).map(([lng, lat]) => ({ - lat, - lng, - })); - setMarkers(polygonMarkers); + if (isModalOpen) { + const coordSource = pendingData || data; + if (coordSource) { + const boundaryFeature = coordSource.features.find(f => f.geometry.type === 'Polygon') as Feature | undefined; + if (boundaryFeature?.geometry.type === 'Polygon') { + const coords = boundaryFeature.geometry.coordinates[0]; + // Remove last coord (which closes the polygon) + const polygonMarkers: LatLngLiteral[] = coords.slice(0, -1).map(([lng, lat]) => ({ + lat, + lng, + })); + setMarkers(polygonMarkers); + } } } - }, [isModalOpen, data]); + }, [isModalOpen, data, pendingData]); const openModal = () => setIsModalOpen(true); const closeModal = () => { setIsModalOpen(false); - // Don't clear markers on close—they persist until re-submitted }; const handleClearMarkers = () => { setMarkers([]); }; + const handleUndoMarker = () => { + if (markers.length > 0) { + setMarkers(prev => prev.slice(0, -1)); + } + }; + const handleSubmit = () => { if (markers.length < 3) return; @@ -71,23 +77,23 @@ export default function ManualCoordinateUpload() { closeModal(); }; - // Check if coordinates have already been submitted - const hasSubmittedCoordinates = data && data.features.length > 0; + // Check if coordinates have already been submitted or are pending + const hasCoordinatesReady = (pendingData && pendingData.features.length > 0) || (data && data.features.length > 0); return ( <> @@ -126,7 +132,7 @@ export default function ManualCoordinateUpload() { - + @@ -136,8 +142,8 @@ export default function ManualCoordinateUpload() { minWidth: 0, borderRadius: 2, overflow: 'hidden', - border: '1px solid rgba(255, 255, 255, 0.12)', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)' + border: `1px solid ${COLORS.whiteVeryLow}`, + boxShadow: `0 4px 12px ${COLORS.blackMedium}` }}> @@ -154,8 +160,8 @@ export default function ManualCoordinateUpload() { Click on the map to place boundary markers for your field + + • Drag markers to adjust their position + Place at least 3 markers to define a field boundary (polygon will appear automatically) - • Use Clear Markers to start over if needed + • Use Undo to remove the last marker or Clear All to start over • Click Submit when you're satisfied with your field boundaries @@ -180,63 +189,62 @@ export default function ManualCoordinateUpload() { - {/* Status */} - - - - Markers placed: - - {markers.length >= 3 && ( - - Boundary defined - - )} - - - {markers.length} - - - {/* Spacer to push buttons to bottom */} {/* Action Buttons */} - + {/* Undo and Clear buttons in a row */} + + + + + @@ -262,4 +270,4 @@ export default function ManualCoordinateUpload() {
) -} \ No newline at end of file +} diff --git a/frontend/src/components/SubmitSection.tsx b/frontend/src/features/farm/SubmitSection.tsx similarity index 90% rename from frontend/src/components/SubmitSection.tsx rename to frontend/src/features/farm/SubmitSection.tsx index 83cc3ae..aeff7fc 100644 --- a/frontend/src/components/SubmitSection.tsx +++ b/frontend/src/features/farm/SubmitSection.tsx @@ -1,5 +1,5 @@ import { Box, Button, Stack, Typography } from '@mui/material'; -import { COLORS } from '../styles/colors'; +import { COLORS } from '../../styles/colors'; interface SubmitSectionProps { coordsReady: boolean; @@ -21,7 +21,7 @@ export default function SubmitSection({ coordsReady, onSubmit }: SubmitSectionPr sx={{ px: 4, backgroundColor: COLORS.indigo, - '&:hover': { backgroundColor: '#7a81ff' }, + '&:hover': { backgroundColor: COLORS.indigoHover }, '&:disabled': { backgroundColor: COLORS.indigo, opacity: 0.6, @@ -38,7 +38,7 @@ export default function SubmitSection({ coordsReady, onSubmit }: SubmitSectionPr )} {coordsReady && ( - Boundary received — ready to submit + Boundary received - ready to submit )} diff --git a/frontend/src/components/YieldFileUpload.tsx b/frontend/src/features/farm/YieldFileUpload.tsx similarity index 92% rename from frontend/src/components/YieldFileUpload.tsx rename to frontend/src/features/farm/YieldFileUpload.tsx index e44ba62..ac3e80b 100644 --- a/frontend/src/components/YieldFileUpload.tsx +++ b/frontend/src/features/farm/YieldFileUpload.tsx @@ -2,8 +2,8 @@ import { Box, Button, IconButton, Stack, Typography, CircularProgress } from "@m import { styled } from "@mui/material/styles"; import React from "react"; import CloseIcon from "@mui/icons-material/Close"; -import { uploadYieldFile } from "../services/fileUploadService"; -import { COLORS } from "../styles/colors"; +import { uploadYieldFile } from "../../services/fileUploadService"; +import { COLORS } from "../../styles/colors"; const VisuallyHiddenInput = styled('input')({ clip: 'rect(0 0 0 0)', @@ -68,7 +68,7 @@ export default function YieldFileUpload(props: { onSelect?: (file: File | null) textTransform: 'none', fontSize: '1rem', backgroundColor: COLORS.indigo, - '&:hover': { backgroundColor: '#7a81ff' } + '&:hover': { backgroundColor: COLORS.indigoHover } }} > Choose file @@ -85,7 +85,7 @@ export default function YieldFileUpload(props: { onSelect?: (file: File | null) sx={{ alignItems: 'center', padding: 1.5, - backgroundColor: 'rgba(100, 108, 255, 0.1)', + backgroundColor: COLORS.indigoLight, border: `1px solid ${COLORS.indigo}`, borderRadius: 1, minWidth: 300, @@ -121,7 +121,7 @@ export default function YieldFileUpload(props: { onSelect?: (file: File | null) textTransform: 'none', fontSize: '1rem', backgroundColor: COLORS.indigo, - '&:hover': { backgroundColor: '#7a81ff' } + '&:hover': { backgroundColor: COLORS.indigoHover } }} > {isLoading ? : null} @@ -130,4 +130,4 @@ export default function YieldFileUpload(props: { onSelect?: (file: File | null) )} ); -} \ No newline at end of file +} diff --git a/frontend/src/features/farm/index.ts b/frontend/src/features/farm/index.ts new file mode 100644 index 0000000..cda6ef3 --- /dev/null +++ b/frontend/src/features/farm/index.ts @@ -0,0 +1,9 @@ +export { default as FarmBiocharForm } from './FarmBiocharForm'; +export { default as ManualCoordinateUpload } from './ManualCoordinateUpload'; +export { default as BudgetSettings } from './BudgetSettings'; +export { default as FieldsList } from './FieldsList'; +export type { FieldEntry } from './FieldsList'; +export { default as FileUploadSection } from './FileUploadSection'; +export { default as SubmitSection } from './SubmitSection'; +export { default as CoordinateFileUpload } from './CoordinateFileUpload'; +export { default as YieldFileUpload } from './YieldFileUpload'; diff --git a/frontend/src/features/index.ts b/frontend/src/features/index.ts new file mode 100644 index 0000000..781f92b --- /dev/null +++ b/frontend/src/features/index.ts @@ -0,0 +1,4 @@ +export * from './auth'; +export * from './farm'; +export * from './map'; +export * from './prescriptions'; diff --git a/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx b/frontend/src/features/map/InteractiveFarmMap.tsx similarity index 62% rename from frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx rename to frontend/src/features/map/InteractiveFarmMap.tsx index 1353446..f143800 100644 --- a/frontend/src/components/InteractiveFarmMap/InteractiveFarmMap.tsx +++ b/frontend/src/features/map/InteractiveFarmMap.tsx @@ -4,9 +4,9 @@ import { type LatLngLiteral } from "leaflet"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; import { Box } from "@mui/material"; +import { COLORS } from "../../styles/colors"; // temporary workaround for marker icon clash between Vite and React Leaflet -// Cast to any to silence TS error on private property access. // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ @@ -30,12 +30,55 @@ const ClickHandler: React.FC = ({ markers, setMarkers }) => { return null; }; +interface DraggableMarkerProps { + position: LatLngLiteral; + index: number; + onDragEnd: (index: number, newPosition: LatLngLiteral) => void; +} + +const DraggableMarker: React.FC = ({ position, index, onDragEnd }) => { + const markerRef = React.useRef(null); + + const eventHandlers = React.useMemo( + () => ({ + dragend() { + const marker = markerRef.current; + if (marker != null) { + const newPos = marker.getLatLng(); + onDragEnd(index, { lat: newPos.lat, lng: newPos.lng }); + } + }, + }), + [index, onDragEnd] + ); + + return ( + + ); +}; + interface InteractiveFarmMapProps { markers: LatLngLiteral[]; setMarkers: React.Dispatch>; } export default function InteractiveFarmMap({ markers, setMarkers }: InteractiveFarmMapProps) { + const handleMarkerDragEnd = React.useCallback( + (index: number, newPosition: LatLngLiteral) => { + setMarkers((prevMarkers) => { + const updated = [...prevMarkers]; + updated[index] = newPosition; + return updated; + }); + }, + [setMarkers] + ); + return ( {markers.map((position, idx) => ( - + ))} - {markers.length >= 3 && } + {markers.length >= 3 && ( + + )} ); diff --git a/frontend/src/features/map/index.ts b/frontend/src/features/map/index.ts new file mode 100644 index 0000000..a7edbb3 --- /dev/null +++ b/frontend/src/features/map/index.ts @@ -0,0 +1 @@ +export { default as InteractiveFarmMap } from './InteractiveFarmMap'; diff --git a/frontend/src/features/prescriptions/PrescriptionMapViewer.tsx b/frontend/src/features/prescriptions/PrescriptionMapViewer.tsx new file mode 100644 index 0000000..4d6b931 --- /dev/null +++ b/frontend/src/features/prescriptions/PrescriptionMapViewer.tsx @@ -0,0 +1,647 @@ +import { Box, Typography, Button, Container, Paper, Stack } from "@mui/material"; +import { COLORS } from '../../styles/colors'; +import React from "react"; +import { useNavigate } from 'react-router'; +import { useCoordinates } from '../../contexts/CoordinateContext'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import MapIcon from '@mui/icons-material/Map'; +import GridOnIcon from '@mui/icons-material/GridOn'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; + +type LatLng = { lat: number; lng: number }; + +interface GridCell { + bounds: L.LatLngBounds; + paybackPeriod: number; + applicationRate: number; +} + +// Color scale for payback period (1-10) +const getColorForPayback = (paybackPeriod: number): string => { + if (paybackPeriod <= 2) return COLORS.dataGreen; // green + if (paybackPeriod <= 4) return COLORS.dataLightGreen; // light green + if (paybackPeriod <= 6) return COLORS.dataYellow; // yellow + if (paybackPeriod <= 8) return COLORS.dataOrange; // orange + return COLORS.dataRed; // red +}; + +// Point-in-polygon test using ray casting algorithm +const pointInPolygon = (point: LatLng, polygon: LatLng[]): boolean => { + let inside = false; + const x = point.lng; + const y = point.lat; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].lng; + const yi = polygon[i].lat; + const xj = polygon[j].lng; + const yj = polygon[j].lat; + + if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) { + inside = !inside; + } + } + + return inside; +}; + +// Convert meters to degrees (approximate) +const metersToDegreesLat = (meters: number): number => meters / 111320; +const metersToDegreesLng = (meters: number, lat: number): number => + meters / (111320 * Math.cos(lat * Math.PI / 180)); + +// Generate grid cells inside a polygon boundary +const generateGridCells = (boundary: LatLng[], cellSizeMeters: number): GridCell[] => { + if (boundary.length < 3) return []; + + // Find bounding box + let minLat = Infinity, maxLat = -Infinity; + let minLng = Infinity, maxLng = -Infinity; + + for (const pt of boundary) { + minLat = Math.min(minLat, pt.lat); + maxLat = Math.max(maxLat, pt.lat); + minLng = Math.min(minLng, pt.lng); + maxLng = Math.max(maxLng, pt.lng); + } + + const centerLat = (minLat + maxLat) / 2; + const cellSizeLat = metersToDegreesLat(cellSizeMeters); + const cellSizeLng = metersToDegreesLng(cellSizeMeters, centerLat); + + const cells: GridCell[] = []; + + // Generate grid cells + for (let lat = minLat; lat < maxLat; lat += cellSizeLat) { + for (let lng = minLng; lng < maxLng; lng += cellSizeLng) { + // Check if cell center is inside polygon + const cellCenter: LatLng = { + lat: lat + cellSizeLat / 2, + lng: lng + cellSizeLng / 2, + }; + + if (pointInPolygon(cellCenter, boundary)) { + const bounds = L.latLngBounds( + [lat, lng], + [lat + cellSizeLat, lng + cellSizeLng] + ); + + cells.push({ + bounds, + paybackPeriod: Math.floor(Math.random() * 10) + 1, + applicationRate: 5, + }); + } + } + } + + return cells; +}; + +class GridCanvasLayer extends L.Layer { + private _canvas: HTMLCanvasElement | null = null; + private _cells: GridCell[] = []; + private _mapInstance: L.Map | null = null; + private _onHover: ((cell: GridCell | null, e: MouseEvent) => void) | null = null; + private _hoveredCell: GridCell | null = null; + + constructor(cells: GridCell[], onHover?: (cell: GridCell | null, e: MouseEvent) => void) { + super(); + this._cells = cells; + this._onHover = onHover || null; + } + + onAdd(map: L.Map): this { + this._mapInstance = map; + this._canvas = L.DomUtil.create('canvas', 'leaflet-grid-canvas') as HTMLCanvasElement; + this._canvas.style.position = 'absolute'; + this._canvas.style.pointerEvents = 'auto'; + + const pane = map.getPane('overlayPane'); + if (pane) pane.appendChild(this._canvas); + + map.on('move moveend zoomend resize', this._reset, this); + this._canvas.addEventListener('mousemove', this._onMouseMove.bind(this)); + this._canvas.addEventListener('mouseout', this._onMouseOut.bind(this)); + + this._reset(); + return this; + } + + onRemove(map: L.Map): this { + if (this._canvas?.parentNode) this._canvas.parentNode.removeChild(this._canvas); + map.off('move moveend zoomend resize', this._reset, this); + this._canvas = null; + this._mapInstance = null; + return this; + } + + setCells(cells: GridCell[]): void { + this._cells = cells; + this._reset(); + } + + private _reset(): void { + if (!this._mapInstance || !this._canvas) return; + + const size = this._mapInstance.getSize(); + const bounds = this._mapInstance.getBounds(); + const topLeft = this._mapInstance.latLngToLayerPoint(bounds.getNorthWest()); + + this._canvas.width = size.x; + this._canvas.height = size.y; + this._canvas.style.width = `${size.x}px`; + this._canvas.style.height = `${size.y}px`; + L.DomUtil.setPosition(this._canvas, topLeft); + + this._draw(); + } + + private _draw(): void { + if (!this._mapInstance || !this._canvas) return; + const ctx = this._canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + if (this._cells.length === 0) return; + + const viewBounds = this._mapInstance.getBounds(); + + for (const cell of this._cells) { + if (!viewBounds.intersects(cell.bounds)) continue; + + const sw = this._mapInstance.latLngToContainerPoint(cell.bounds.getSouthWest()); + const ne = this._mapInstance.latLngToContainerPoint(cell.bounds.getNorthEast()); + + const x = sw.x; + const y = ne.y; + const w = ne.x - sw.x; + const h = sw.y - ne.y; + + ctx.globalAlpha = 0.7; + ctx.fillStyle = getColorForPayback(cell.paybackPeriod); + ctx.fillRect(x, y, w, h); + + if (w > 3 && h > 3) { + ctx.globalAlpha = 0.5; + ctx.strokeStyle = COLORS.strokeDark; + ctx.lineWidth = 0.5; + ctx.strokeRect(x, y, w, h); + } + } + + if (this._hoveredCell) { + const sw = this._mapInstance.latLngToContainerPoint(this._hoveredCell.bounds.getSouthWest()); + const ne = this._mapInstance.latLngToContainerPoint(this._hoveredCell.bounds.getNorthEast()); + ctx.globalAlpha = 1; + ctx.strokeStyle = COLORS.whiteFull; + ctx.lineWidth = 2; + ctx.strokeRect(sw.x, ne.y, ne.x - sw.x, sw.y - ne.y); + } + + ctx.globalAlpha = 1; + } + + private _onMouseMove(e: MouseEvent): void { + if (!this._mapInstance || !this._canvas) return; + + const rect = this._canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + let foundCell: GridCell | null = null; + + for (const cell of this._cells) { + const sw = this._mapInstance.latLngToContainerPoint(cell.bounds.getSouthWest()); + const ne = this._mapInstance.latLngToContainerPoint(cell.bounds.getNorthEast()); + + if (x >= sw.x && x <= ne.x && y >= ne.y && y <= sw.y) { + foundCell = cell; + break; + } + } + + if (foundCell !== this._hoveredCell) { + this._hoveredCell = foundCell; + this._draw(); + if (this._onHover) this._onHover(foundCell, e); + } + } + + private _onMouseOut(): void { + if (this._hoveredCell) { + this._hoveredCell = null; + this._draw(); + if (this._onHover) this._onHover(null, new MouseEvent('mouseout')); + } + } +} + +// Legend component +const PaybackLegend: React.FC = () => { + const legendItems = [ + { range: '1-2 years', color: COLORS.dataGreen, label: 'Excellent' }, + { range: '3-4 years', color: COLORS.dataLightGreen, label: 'Good' }, + { range: '5-6 years', color: COLORS.dataYellow, label: 'Moderate' }, + { range: '7-8 years', color: COLORS.dataOrange, label: 'Fair' }, + { range: '9-10 years', color: COLORS.dataRed, label: 'Poor' }, + ]; + + return ( + + + Payback Period Legend + + + {legendItems.map((item) => ( + + + + {item.range} + + + {item.label} + + + ))} + + + ); +}; + +// Stats panel component +interface StatsPanelProps { + cells: GridCell[]; +} + +const StatsPanel: React.FC = ({ cells }) => { + const avgPayback = cells.length > 0 + ? (cells.reduce((sum, c) => sum + c.paybackPeriod, 0) / cells.length).toFixed(1) + : '0'; + + return ( + + + + Analysis Summary + + + + + + Total Grid Cells + + + {cells.length.toLocaleString()} + + + + + + Avg. Payback Period + + + {avgPayback} years + + + + + ); +}; + +export default function PrescriptionMapViewer() { + const navigate = useNavigate(); + const { data: committedCoords, hasCoordinates, isLoading, clearCoordinateData, formSubmitted, setFormSubmitted } = useCoordinates(); + + const mapContainerRef = React.useRef(null); + const mapRef = React.useRef(null); + const gridLayerRef = React.useRef(null); + const boundaryLayerRef = React.useRef(null); + const tooltipRef = React.useRef(null); + + const [cells, setCells] = React.useState([]); + + // Extract boundary coordinates from context + const boundaryCoords = React.useMemo(() => { + // From context (GeoJSON FeatureCollection) + if (committedCoords && (committedCoords as any).type === 'FeatureCollection') { + const fc = committedCoords as any; + const polyFeature = fc.features?.find( + (f: any) => f.geometry?.type === 'Polygon' + ); + + if (polyFeature?.geometry?.coordinates?.[0]) { + return polyFeature.geometry.coordinates[0].map((coord: [number, number]) => ({ + lat: coord[1], + lng: coord[0], + })); + } + } + + return []; + }, [committedCoords]); + + // Initialize map + React.useEffect(() => { + if (!mapContainerRef.current || mapRef.current) return; + + const map = L.map(mapContainerRef.current, { + center: [46.7, -116.96], + zoom: 14, + zoomControl: false, + }); + + // Add zoom control to bottom right + L.control.zoom({ position: 'bottomright' }).addTo(map); + + mapRef.current = map; + + // Add ESRI satellite imagery + L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri', + maxZoom: 20, + }).addTo(map); + + // Add ESRI labels + L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', { + maxZoom: 20, + }).addTo(map); + + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.style.cssText = ` + position: absolute; + background: rgba(0,0,0,0.85); + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + color: #fff; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + display: none; + border: 1px solid rgba(255,255,255,0.1); + `; + document.body.appendChild(tooltip); + tooltipRef.current = tooltip; + + return () => { + if (tooltipRef.current) { + document.body.removeChild(tooltipRef.current); + tooltipRef.current = null; + } + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, []); + + // Update boundary and grid when data changes + React.useEffect(() => { + const map = mapRef.current; + if (!map || boundaryCoords.length < 3 || !formSubmitted) return; + + // Remove old layers + if (boundaryLayerRef.current) { + map.removeLayer(boundaryLayerRef.current); + } + if (gridLayerRef.current) { + map.removeLayer(gridLayerRef.current); + } + + // Draw boundary line + const latLngs = boundaryCoords.map(c => L.latLng(c.lat, c.lng)); + latLngs.push(latLngs[0]); + + const boundaryLine = L.polyline(latLngs, { + color: COLORS.gold, + weight: 3, + opacity: 1, + }).addTo(map); + + boundaryLayerRef.current = boundaryLine; + + // Generate grid cells + const generatedCells = generateGridCells(boundaryCoords, 25); + setCells(generatedCells); + console.log(`[PrescriptionMapViewer] Generated ${generatedCells.length} grid cells`); + + // Hover handler for tooltip + const handleHover = (cell: GridCell | null, e: MouseEvent) => { + if (!tooltipRef.current) return; + + if (cell) { + tooltipRef.current.innerHTML = ` +
Cell Details
+
Payback: ${cell.paybackPeriod} years
+
Application Rate: ${cell.applicationRate} tons/acre
+ `; + tooltipRef.current.style.display = 'block'; + tooltipRef.current.style.left = `${e.clientX + 12}px`; + tooltipRef.current.style.top = `${e.clientY + 12}px`; + } else { + tooltipRef.current.style.display = 'none'; + } + }; + + // Add grid canvas layer + const gridLayer = new GridCanvasLayer(generatedCells, handleHover); + gridLayer.addTo(map); + gridLayerRef.current = gridLayer; + + // Fit bounds to boundary + const bounds = L.latLngBounds(latLngs); + map.fitBounds(bounds, { padding: [60, 60] }); + + }, [boundaryCoords, formSubmitted]); + + // Clear grid when form not submitted + React.useEffect(() => { + if (!formSubmitted && mapRef.current) { + if (boundaryLayerRef.current) { + mapRef.current.removeLayer(boundaryLayerRef.current); + boundaryLayerRef.current = null; + } + if (gridLayerRef.current) { + mapRef.current.removeLayer(gridLayerRef.current); + gridLayerRef.current = null; + } + setCells([]); + } + }, [formSubmitted]); + + const handleReset = () => { + clearCoordinateData(); + setFormSubmitted(false); + navigate('/'); + }; + + // Empty state + if (!isLoading && (!hasCoordinates || !formSubmitted)) { + return ( + + + + + + + + + No Prescription Data + + + To view prescription maps and biochar application recommendations, please submit your farm configuration with boundary coordinates. + + + + + + + ); + } + + return ( + + {/* Header */} + + + + Prescription Map + + + Biochar application recommendations based on your field analysis + + + + + + + + + {/* Main content */} + + {/* Map container */} + +
+ + {/* Info overlay */} + + + + Hover over cells to see details + + + + + {/* Sidebar */} + + + + + + + ); +} diff --git a/frontend/src/features/prescriptions/index.ts b/frontend/src/features/prescriptions/index.ts new file mode 100644 index 0000000..aa248ca --- /dev/null +++ b/frontend/src/features/prescriptions/index.ts @@ -0,0 +1 @@ +export { default as PrescriptionMapViewer } from './PrescriptionMapViewer'; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 8265198..d8e4876 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,7 +1,7 @@ import { Box, Button, Typography, Container } from '@mui/material'; import { useNavigate } from 'react-router'; import { useAuth } from '../contexts/AuthContext'; -import FarmBiocharForm from '../components/FarmBiocharForm'; +import { FarmBiocharForm } from '../features/farm'; import { COLORS } from '../styles/colors'; const HomePage = () => { @@ -41,7 +41,7 @@ const HomePage = () => { diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 32c7f20..5aa3810 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,8 +1,11 @@ import { Box, Typography, Container } from "@mui/material"; -import FarmBiocharForm from "../components/FarmBiocharForm"; +import { FarmBiocharForm } from "../features/farm"; import { COLORS } from "../styles/colors"; +import { useAuth } from "../contexts/AuthContext"; const LandingPage = () => { + const { isAuthenticated } = useAuth(); + return ( { - + {isAuthenticated && } ); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 4f6549e..80ab256 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -43,7 +43,7 @@ const LoginPage = () => { sx={{ width: '100%', maxWidth: 400, - backgroundColor: '#1a1a1a', + backgroundColor: COLORS.bgCard, padding: 3, borderRadius: 2, boxShadow: `0 4px 12px ${COLORS.blackMedium}`, diff --git a/frontend/src/pages/PrescriptionsPage.tsx b/frontend/src/pages/PrescriptionsPage.tsx index 631f253..7198d93 100644 --- a/frontend/src/pages/PrescriptionsPage.tsx +++ b/frontend/src/pages/PrescriptionsPage.tsx @@ -1,4 +1,4 @@ -import PrescriptionMapViewer from "../components/PrescriptionMapViewer"; +import { PrescriptionMapViewer } from "../features/prescriptions"; export default function PrescriptionsPage() { return ( diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx index 285d39d..e1406d9 100644 --- a/frontend/src/pages/SignupPage.tsx +++ b/frontend/src/pages/SignupPage.tsx @@ -50,7 +50,7 @@ const SignupPage = () => { sx={{ width: '100%', maxWidth: 500, - backgroundColor: '#1a1a1a', + backgroundColor: COLORS.bgCard, padding: 3, borderRadius: 2, boxShadow: `0 4px 12px ${COLORS.blackMedium}`, diff --git a/frontend/src/styles/colors.ts b/frontend/src/styles/colors.ts index a9f080e..5a39698 100644 --- a/frontend/src/styles/colors.ts +++ b/frontend/src/styles/colors.ts @@ -4,11 +4,53 @@ export const COLORS = { whiteHigh: 'rgba(255, 255, 255, 0.87)', // for primary text whiteMedium: 'rgba(255, 255, 255, 0.5)', // for placeholders or secondary text whiteLow: 'rgba(255, 255, 255, 0.23)', // for borders or subtle accents + whiteVeryLow: 'rgba(255, 255, 255, 0.12)', // for subtle dividers + whiteHover: 'rgba(255, 255, 255, 0.08)', // for hover states on dark bg + whiteDisabled: 'rgba(255, 255, 255, 0.3)', // for disabled text // blacks + blackFull: '#000000', + blackHigh: 'rgba(0, 0, 0, 0.85)', // for dark overlays blackMedium: 'rgba(0, 0, 0, 0.5)', // for shadows or overlays blackLow: 'rgba(0, 0, 0, 0.3)', // for subtle shadows + blackOverlay: 'rgba(0, 0, 0, 0.7)', // for modal overlays - // primary accent (for now, styling will change heavily) - indigo: '#646cff', // for links or buttons + // grays / backgrounds + bgDark: '#0a0a0a', // very dark background + bgCard: '#1a1a1a', // card background + bgPage: '#242424', // page background + textDark: '#111', // dark text on light backgrounds + strokeDark: '#222', // dark stroke for grid cells + + // primary accent - indigo + indigo: '#646cff', // primary accent for links/buttons + indigoHover: '#7a81ff', // hover state for indigo + indigoLight: 'rgba(100, 108, 255, 0.1)', // light indigo background + indigoMedium: 'rgba(100, 108, 255, 0.15)', // medium indigo background + indigoVeryLight: 'rgba(100, 108, 255, 0.05)', // very light indigo background + indigoBorder: 'rgba(99, 102, 241, 0.3)', // indigo border + indigoText: '#a5b4fc', // light indigo for text + + // error / danger - red + error: '#ef4444', + errorHover: '#c62828', + errorBg: '#d32f2f', + errorLight: 'rgba(239, 68, 68, 0.1)', + errorBorder: 'rgba(239, 68, 68, 0.4)', + + // warning - amber/yellow + warning: '#fbbf24', + warningBorder: 'rgba(251, 191, 36, 0.5)', + warningLight: 'rgba(251, 191, 36, 0.1)', + + // accent - gold (for map highlights) + gold: '#FFD700', + + // data visualization - payback period scale + dataGreen: '#1a9641', // excellent (1-2 years) + dataLightGreen: '#a6d96a', // good (3-4 years) + dataYellow: '#f9d423', // moderate (5-6 years) + dataOrange: '#f58634', // fair (7-8 years) + dataRed: '#d7191c', // poor (9-10 years) + dataDefault: '#90caf9', // fallback/default }; diff --git a/frontend/src/types/maplibre/priorityStyle.ts b/frontend/src/types/maplibre/priorityStyle.ts index 4919c0d..cd12c5d 100644 --- a/frontend/src/types/maplibre/priorityStyle.ts +++ b/frontend/src/types/maplibre/priorityStyle.ts @@ -1,3 +1,5 @@ +import { COLORS } from '../../styles/colors'; + // MapLibre GL paint expression for fill-color. // Primary: derive color by numeric ROI metric `paybackPeriod` (e.g., months). // - Quickest payback -> red (urgent) @@ -8,20 +10,20 @@ export const priorityFillColorExpression = [ ['has', 'paybackPeriod'], // step(paybackPeriod, baseColor, stop1, color1, stop2, color2, ...) ['step', ['get', 'paybackPeriod'], - '#d7191c', - 6, '#f58634', - 12, '#f9d423', - 18, '#a6d96a', - 24, '#1a9641' + COLORS.dataRed, + 6, COLORS.dataOrange, + 12, COLORS.dataYellow, + 18, COLORS.dataLightGreen, + 24, COLORS.dataGreen ], // Fallback to categorical priority mapping ['match', ['downcase', ['coalesce', ['to-string', ['get', 'priority']], ['to-string', ['get', 'priorityRange']], '' ]], - 'high', '#d7191c', - 'medium-high', '#f58634', - 'medium', '#f9d423', - 'medium-low', '#a6d96a', - 'low', '#1a9641', - '#90caf9' + 'high', COLORS.dataRed, + 'medium-high', COLORS.dataOrange, + 'medium', COLORS.dataYellow, + 'medium-low', COLORS.dataLightGreen, + 'low', COLORS.dataGreen, + COLORS.dataDefault ] ]; From 9b9c187fa9d04319870130c7ac8f203603ba401d Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 26 Dec 2025 22:01:20 -0700 Subject: [PATCH 25/25] feat: update styling --- frontend/src/components/Header.tsx | 11 +- .../PrescriptionMapViewer.tsx | 478 ------------------ frontend/src/features/farm/BudgetSettings.tsx | 1 + .../src/features/farm/FarmBiocharForm.tsx | 92 ++-- frontend/src/pages/HomePage.tsx | 41 +- 5 files changed, 64 insertions(+), 559 deletions(-) delete mode 100644 frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1b2ad88..6c7a22d 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -34,18 +34,25 @@ const Header = () => { {isAuthenticated ? ( <> + ) : ( diff --git a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx b/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx deleted file mode 100644 index 0d82197..0000000 --- a/frontend/src/components/PrescriptionMapViewer/PrescriptionMapViewer.tsx +++ /dev/null @@ -1,478 +0,0 @@ -import { Box, Typography, Button, Container } from "@mui/material"; -import { COLORS } from '../../styles/colors'; -import React from "react"; -import { useNavigate } from 'react-router'; -import { useCoordinates } from '../../contexts/CoordinateContext'; -import L from 'leaflet'; -import 'leaflet/dist/leaflet.css'; - -type LatLng = { lat: number; lng: number }; - -interface GridCell { - bounds: L.LatLngBounds; - paybackPeriod: number; - applicationRate: number; -} - -interface PrescriptionMapViewerProps { - data?: LatLng[] | null; - height?: number | string; - width?: number | string; -} - -// Color scale for payback period (1-10) -const getColorForPayback = (paybackPeriod: number): string => { - if (paybackPeriod <= 2) return COLORS.dataGreen; // green - if (paybackPeriod <= 4) return COLORS.dataLightGreen; // light green - if (paybackPeriod <= 6) return COLORS.dataYellow; // yellow - if (paybackPeriod <= 8) return COLORS.dataOrange; // orange - return COLORS.dataRed; // red -}; - -// Point-in-polygon test using ray casting algorithm -const pointInPolygon = (point: LatLng, polygon: LatLng[]): boolean => { - let inside = false; - const x = point.lng; - const y = point.lat; - - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const xi = polygon[i].lng; - const yi = polygon[i].lat; - const xj = polygon[j].lng; - const yj = polygon[j].lat; - - if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) { - inside = !inside; - } - } - - return inside; -}; - -// Convert meters to degrees (approximate) -const metersToDegreesLat = (meters: number): number => meters / 111320; -const metersToDegreesLng = (meters: number, lat: number): number => - meters / (111320 * Math.cos(lat * Math.PI / 180)); - -// Generate grid cells inside a polygon boundary -const generateGridCells = (boundary: LatLng[], cellSizeMeters: number): GridCell[] => { - if (boundary.length < 3) return []; - - // Find bounding box - let minLat = Infinity, maxLat = -Infinity; - let minLng = Infinity, maxLng = -Infinity; - - for (const pt of boundary) { - minLat = Math.min(minLat, pt.lat); - maxLat = Math.max(maxLat, pt.lat); - minLng = Math.min(minLng, pt.lng); - maxLng = Math.max(maxLng, pt.lng); - } - - const centerLat = (minLat + maxLat) / 2; - const cellSizeLat = metersToDegreesLat(cellSizeMeters); - const cellSizeLng = metersToDegreesLng(cellSizeMeters, centerLat); - - const cells: GridCell[] = []; - - // Generate grid cells - for (let lat = minLat; lat < maxLat; lat += cellSizeLat) { - for (let lng = minLng; lng < maxLng; lng += cellSizeLng) { - // Check if cell center is inside polygon - const cellCenter: LatLng = { - lat: lat + cellSizeLat / 2, - lng: lng + cellSizeLng / 2, - }; - - if (pointInPolygon(cellCenter, boundary)) { - const bounds = L.latLngBounds( - [lat, lng], - [lat + cellSizeLat, lng + cellSizeLng] - ); - - cells.push({ - bounds, - paybackPeriod: Math.floor(Math.random() * 10) + 1, - applicationRate: 5, - }); - } - } - } - - return cells; -}; - -class GridCanvasLayer extends L.Layer { - private _canvas: HTMLCanvasElement | null = null; - private _cells: GridCell[] = []; - private _mapInstance: L.Map | null = null; - private _onHover: ((cell: GridCell | null, e: MouseEvent) => void) | null = null; - private _hoveredCell: GridCell | null = null; - - constructor(cells: GridCell[], onHover?: (cell: GridCell | null, e: MouseEvent) => void) { - super(); - this._cells = cells; - this._onHover = onHover || null; - } - - onAdd(map: L.Map): this { - this._mapInstance = map; - - // Main canvas - this._canvas = L.DomUtil.create('canvas', 'leaflet-grid-canvas') as HTMLCanvasElement; - this._canvas.style.position = 'absolute'; - this._canvas.style.pointerEvents = 'auto'; - - const pane = map.getPane('overlayPane'); - if (pane) pane.appendChild(this._canvas); - - // Events - include 'move' for smooth panning - map.on('move moveend zoomend resize', this._reset, this); - this._canvas.addEventListener('mousemove', this._onMouseMove.bind(this)); - this._canvas.addEventListener('mouseout', this._onMouseOut.bind(this)); - - this._reset(); - return this; - } - - onRemove(map: L.Map): this { - if (this._canvas?.parentNode) this._canvas.parentNode.removeChild(this._canvas); - map.off('move moveend zoomend resize', this._reset, this); - this._canvas = null; - this._mapInstance = null; - return this; - } - - setCells(cells: GridCell[]): void { - this._cells = cells; - this._reset(); - } - - private _reset(): void { - if (!this._mapInstance || !this._canvas) return; - - const size = this._mapInstance.getSize(); - const bounds = this._mapInstance.getBounds(); - const topLeft = this._mapInstance.latLngToLayerPoint(bounds.getNorthWest()); - - this._canvas.width = size.x; - this._canvas.height = size.y; - this._canvas.style.width = `${size.x}px`; - this._canvas.style.height = `${size.y}px`; - L.DomUtil.setPosition(this._canvas, topLeft); - - this._draw(); - } - - private _draw(): void { - if (!this._mapInstance || !this._canvas) return; - const ctx = this._canvas.getContext('2d'); - if (!ctx) return; - - ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); - - if (this._cells.length === 0) return; - - // Get visible bounds for culling - const viewBounds = this._mapInstance.getBounds(); - - // Draw all cells (already filtered to polygon in generateGridCells) - for (const cell of this._cells) { - // Skip cells outside the current view - if (!viewBounds.intersects(cell.bounds)) continue; - - const sw = this._mapInstance.latLngToContainerPoint(cell.bounds.getSouthWest()); - const ne = this._mapInstance.latLngToContainerPoint(cell.bounds.getNorthEast()); - - const x = sw.x; - const y = ne.y; - const w = ne.x - sw.x; - const h = sw.y - ne.y; - - // Fill cell - ctx.globalAlpha = 0.7; - ctx.fillStyle = getColorForPayback(cell.paybackPeriod); - ctx.fillRect(x, y, w, h); - - // Stroke cell (only if large enough to see) - if (w > 3 && h > 3) { - ctx.globalAlpha = 0.5; - ctx.strokeStyle = COLORS.strokeDark; - ctx.lineWidth = 0.5; - ctx.strokeRect(x, y, w, h); - } - } - - // Draw hover highlight - if (this._hoveredCell) { - const sw = this._mapInstance.latLngToContainerPoint(this._hoveredCell.bounds.getSouthWest()); - const ne = this._mapInstance.latLngToContainerPoint(this._hoveredCell.bounds.getNorthEast()); - - ctx.globalAlpha = 1; - ctx.strokeStyle = COLORS.whiteFull; - ctx.lineWidth = 2; - ctx.strokeRect(sw.x, ne.y, ne.x - sw.x, sw.y - ne.y); - } - - ctx.globalAlpha = 1; - } - - private _onMouseMove(e: MouseEvent): void { - if (!this._mapInstance || !this._canvas) return; - - const rect = this._canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - let foundCell: GridCell | null = null; - - for (const cell of this._cells) { - const sw = this._mapInstance.latLngToContainerPoint(cell.bounds.getSouthWest()); - const ne = this._mapInstance.latLngToContainerPoint(cell.bounds.getNorthEast()); - - if (x >= sw.x && x <= ne.x && y >= ne.y && y <= sw.y) { - foundCell = cell; - break; - } - } - - if (foundCell !== this._hoveredCell) { - this._hoveredCell = foundCell; - this._draw(); - if (this._onHover) this._onHover(foundCell, e); - } - } - - private _onMouseOut(): void { - if (this._hoveredCell) { - this._hoveredCell = null; - this._draw(); - if (this._onHover) this._onHover(null, new MouseEvent('mouseout')); - } - } -} - -export default function PrescriptionMapViewer({ data, height = '400px', width = '100%' }: PrescriptionMapViewerProps) { - const navigate = useNavigate(); - const { data: committedCoords, hasCoordinates, isLoading, clearCoordinateData, formSubmitted, setFormSubmitted } = useCoordinates(); - - const mapContainerRef = React.useRef(null); - const mapRef = React.useRef(null); - const gridLayerRef = React.useRef(null); - const boundaryLayerRef = React.useRef(null); - const tooltipRef = React.useRef(null); - - // Extract boundary coordinates from context or data prop - const boundaryCoords = React.useMemo(() => { - // Direct array of coords - if (Array.isArray(data) && data.length > 0 && 'lat' in data[0]) { - return data as LatLng[]; - } - - // From context (GeoJSON FeatureCollection) - if (committedCoords && (committedCoords as any).type === 'FeatureCollection') { - const fc = committedCoords as any; - const polyFeature = fc.features?.find( - (f: any) => f.geometry?.type === 'Polygon' - ); - - if (polyFeature?.geometry?.coordinates?.[0]) { - // GeoJSON is [lng, lat], convert to {lat, lng} - return polyFeature.geometry.coordinates[0].map((coord: [number, number]) => ({ - lat: coord[1], - lng: coord[0], - })); - } - } - - return []; - }, [data, committedCoords]); - - // Initialize map - React.useEffect(() => { - if (!mapContainerRef.current || mapRef.current) return; - - const map = L.map(mapContainerRef.current, { - center: [46.7, -116.96], - zoom: 14, - }); - - mapRef.current = map; - - // Add ESRI satellite imagery - L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { - attribution: 'Tiles © Esri', - maxZoom: 20, - }).addTo(map); - - // Add ESRI labels - L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', { - maxZoom: 20, - }).addTo(map); - - // Create tooltip element - const tooltip = document.createElement('div'); - tooltip.style.cssText = ` - position: absolute; - background: white; - padding: 6px 10px; - border-radius: 4px; - font-size: 12px; - color: #111; - pointer-events: none; - z-index: 1000; - box-shadow: 0 2px 6px rgba(0,0,0,0.3); - display: none; - `; - document.body.appendChild(tooltip); - tooltipRef.current = tooltip; - - return () => { - if (tooltipRef.current) { - document.body.removeChild(tooltipRef.current); - tooltipRef.current = null; - } - if (mapRef.current) { - mapRef.current.remove(); - mapRef.current = null; - } - }; - }, []); - - // Update boundary and grid when data changes - React.useEffect(() => { - const map = mapRef.current; - if (!map || boundaryCoords.length < 3 || !formSubmitted) return; - - // Remove old layers - if (boundaryLayerRef.current) { - map.removeLayer(boundaryLayerRef.current); - } - if (gridLayerRef.current) { - map.removeLayer(gridLayerRef.current); - } - - // Draw boundary line - const latLngs = boundaryCoords.map(c => L.latLng(c.lat, c.lng)); - latLngs.push(latLngs[0]); // Close the polygon - - const boundaryLine = L.polyline(latLngs, { - color: COLORS.gold, - weight: 3, - opacity: 1, - }).addTo(map); - - boundaryLayerRef.current = boundaryLine; - - // Generate grid cells (100m x 100m) - const cells = generateGridCells(boundaryCoords, 25); - console.log(`[PrescriptionMapViewer] Generated ${cells.length} grid cells`); - - // Hover handler for tooltip - const handleHover = (cell: GridCell | null, e: MouseEvent) => { - if (!tooltipRef.current) return; - - if (cell) { - tooltipRef.current.innerHTML = ` - Payback Period: ${cell.paybackPeriod}
- Application Rate: ${cell.applicationRate} - `; - tooltipRef.current.style.display = 'block'; - tooltipRef.current.style.left = `${e.clientX + 12}px`; - tooltipRef.current.style.top = `${e.clientY + 12}px`; - } else { - tooltipRef.current.style.display = 'none'; - } - }; - - // Add grid canvas layer - const gridLayer = new GridCanvasLayer(cells, handleHover); - gridLayer.addTo(map); - gridLayerRef.current = gridLayer; - - // Fit bounds to boundary - const bounds = L.latLngBounds(latLngs); - map.fitBounds(bounds, { padding: [40, 40] }); - - }, [boundaryCoords, formSubmitted]); - - // Clear grid when form not submitted - React.useEffect(() => { - if (!formSubmitted && mapRef.current) { - if (boundaryLayerRef.current) { - mapRef.current.removeLayer(boundaryLayerRef.current); - boundaryLayerRef.current = null; - } - if (gridLayerRef.current) { - mapRef.current.removeLayer(gridLayerRef.current); - gridLayerRef.current = null; - } - } - }, [formSubmitted]); - - return ( - - {/* Guard: show message if no coordinates or form not submitted */} - {!isLoading && (!hasCoordinates || !formSubmitted) && ( - - - - No Farm Coordinates Found - - - To view prescription maps, please submit your farm configuration with boundary coordinates. - - - - - )} - - {/* Show map only when coordinates are committed AND form was submitted */} - {!isLoading && hasCoordinates && formSubmitted && ( - <> - - - - - - Displaying farm boundary and grid cells with estimated payback periods. Hover over cells for details. - - - -
- - - )} - - ); -} diff --git a/frontend/src/features/farm/BudgetSettings.tsx b/frontend/src/features/farm/BudgetSettings.tsx index 12059c3..24a6567 100644 --- a/frontend/src/features/farm/BudgetSettings.tsx +++ b/frontend/src/features/farm/BudgetSettings.tsx @@ -21,6 +21,7 @@ export default function BudgetSettings({ globalMax, onChange }: BudgetSettingsPr value={globalMax} onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))} size="small" + inputProps={{ maxLength: 10 }} slotProps={{ input: { startAdornment: ( diff --git a/frontend/src/features/farm/FarmBiocharForm.tsx b/frontend/src/features/farm/FarmBiocharForm.tsx index 80ba3d7..18ecdd5 100644 --- a/frontend/src/features/farm/FarmBiocharForm.tsx +++ b/frontend/src/features/farm/FarmBiocharForm.tsx @@ -67,53 +67,6 @@ export default function FarmBiocharForm() { const coordsReady = coordUploaded || hasPendingCoordinates || hasCoordinates; const isPriceValid = field.price !== '' && field.price > 0; - const FormContent = () => ( - - {/* Title Section */} - - - Farm Configuration - - - Configure your field's crop and selling price, set your biochar budget, and upload boundary coordinates to calculate optimal application rates. - - - - {/* Budget Settings */} - - - {/* Field Configuration */} - - - {/* File Upload Section */} - - - {/* Submit Section */} - { - if (!isPriceValid) { - alert('Please enter a valid crop selling price.'); - return; - } - const payload = { globalMax, field }; - console.log('Submit payload', payload); - commitPendingCoordinates(); - setFormSubmitted(true); - closeModal(); - }} - /> - - ); - return ( <> {/* Modal Trigger Button */} @@ -147,7 +100,50 @@ export default function FarmBiocharForm() { - + + {/* Title Section */} + + + Farm Configuration + + + Configure your field's crop and selling price, set your biochar budget, and upload boundary coordinates to calculate optimal application rates. + + + + {/* Budget Settings */} + + + {/* Field Configuration */} + + + {/* File Upload Section */} + + + {/* Submit Section */} + { + if (!isPriceValid) { + alert('Please enter a valid crop selling price.'); + return; + } + const payload = { globalMax, field }; + console.log('Submit payload', payload); + commitPendingCoordinates(); + setFormSubmitted(true); + closeModal(); + }} + /> +
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d8e4876..313c6aa 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,21 +1,10 @@ -import { Box, Button, Typography, Container } from '@mui/material'; -import { useNavigate } from 'react-router'; +import { Box, Typography, Container } from '@mui/material'; import { useAuth } from '../contexts/AuthContext'; import { FarmBiocharForm } from '../features/farm'; import { COLORS } from '../styles/colors'; const HomePage = () => { - const navigate = useNavigate(); - const { user, logout } = useAuth(); - - const handleLogout = async () => { - try { - await logout(); - navigate('/login'); - } catch (error) { - console.error('Logout failed:', error); - } - }; + const { user } = useAuth(); return ( @@ -27,24 +16,14 @@ const HomePage = () => { py: 4, }} > - {/* Header: title + logout */} - - - - Welcome, {user?.first_name || user?.username}! - - - Manage your farm biochar applications and data below. - - - - + {/* Header */} + + + Welcome, {user?.first_name || user?.username}! + + + Manage your farm biochar applications and data below. + {/* Intro boilerplate */}