2026-02-20 22:15:34 +03:30
|
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useCallback } from 'react'
|
|
|
|
|
import type L from 'leaflet'
|
|
|
|
|
import 'leaflet/dist/leaflet.css'
|
|
|
|
|
import 'leaflet-draw/dist/leaflet.draw.css'
|
|
|
|
|
import type { Feature, Polygon } from 'geojson'
|
|
|
|
|
import { createZonedGrid } from './cropZoningUtils'
|
|
|
|
|
import { CROP_COLORS, type CropType, type LayerType, type ZoneFeatureProperties } from './cropZoningTypes'
|
|
|
|
|
|
|
|
|
|
export type MapDrawGeoJSON = Record<string, unknown> | null
|
|
|
|
|
|
|
|
|
|
type CropZoningMapProps = {
|
|
|
|
|
center?: [number, number]
|
|
|
|
|
zoom?: number
|
|
|
|
|
height?: string | number
|
|
|
|
|
activeLayer: LayerType
|
|
|
|
|
onAreaChange?: (geojson: MapDrawGeoJSON) => void
|
|
|
|
|
onZoneClick?: (zone: ZoneFeatureProperties) => void
|
|
|
|
|
optimizationKey?: number
|
|
|
|
|
className?: string
|
2026-02-21 22:05:47 +03:30
|
|
|
/** منطقهٔ اولیه از دیتای ماک؛ وقتی مقدار دارد نقشه فقط نمایشی است و کاربر نمیتواند منطقه را تغییر دهد */
|
|
|
|
|
initialAreaGeoJson?: MapDrawGeoJSON | null
|
|
|
|
|
/** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */
|
|
|
|
|
readOnly?: boolean
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function CropZoningMap({
|
|
|
|
|
center = [35.6892, 51.389],
|
|
|
|
|
zoom = 13,
|
|
|
|
|
height = '100%',
|
|
|
|
|
activeLayer,
|
|
|
|
|
onAreaChange,
|
|
|
|
|
onZoneClick,
|
|
|
|
|
optimizationKey = 0,
|
2026-02-21 22:05:47 +03:30
|
|
|
className = '',
|
|
|
|
|
initialAreaGeoJson = null,
|
|
|
|
|
readOnly = false
|
2026-02-20 22:15:34 +03:30
|
|
|
}: CropZoningMapProps) {
|
|
|
|
|
const mapRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const mapInstanceRef = useRef<L.Map | null>(null)
|
|
|
|
|
const drawnItemsRef = useRef<L.FeatureGroup | null>(null)
|
|
|
|
|
const drawControlRef = useRef<L.Control.Draw | null>(null)
|
|
|
|
|
const zonesLayerRef = useRef<L.GeoJSON | null>(null)
|
|
|
|
|
|
|
|
|
|
const renderZones = useCallback(
|
|
|
|
|
(map: L.Map, polygonFeature: Feature<Polygon>) => {
|
|
|
|
|
if (zonesLayerRef.current) {
|
|
|
|
|
map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const grid = createZonedGrid(polygonFeature)
|
|
|
|
|
const L = (window as unknown as { L: typeof import('leaflet') }).L
|
|
|
|
|
if (!L) return
|
|
|
|
|
|
|
|
|
|
const geoJsonLayer = L.geoJSON(grid as never, {
|
|
|
|
|
style: (feature?: { properties?: ZoneFeatureProperties }) => {
|
|
|
|
|
const props = feature?.properties
|
|
|
|
|
if (!props || activeLayer !== 'crops') {
|
|
|
|
|
return { fillColor: '#94a3b8', fillOpacity: 0.5, weight: 1, color: '#fff' }
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
fillColor: CROP_COLORS[props.crop as CropType],
|
|
|
|
|
fillOpacity: 0.5,
|
|
|
|
|
weight: 1,
|
|
|
|
|
color: '#fff'
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onEachFeature: (feature: { properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => {
|
|
|
|
|
const props = feature?.properties
|
|
|
|
|
if (!props) return
|
|
|
|
|
const layer = leafLayer as L.Polygon
|
|
|
|
|
const cropLabel = props.crop === 'wheat' ? 'گندم' : props.crop === 'canola' ? 'کلزا' : 'زعفران'
|
|
|
|
|
const tooltipContent = `
|
|
|
|
|
<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
|
|
|
|
|
<div style="font-weight: 600; margin-bottom: 6px;">${cropLabel}</div>
|
|
|
|
|
<div>درصد تطابق: ${props.matchPercent}%</div>
|
|
|
|
|
<div>نیاز آب: ${props.waterNeed}</div>
|
|
|
|
|
<div>سود تخمینی: ${props.estimatedProfit}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`
|
|
|
|
|
layer.bindTooltip(tooltipContent, {
|
|
|
|
|
sticky: true,
|
|
|
|
|
className: 'zone-tooltip',
|
|
|
|
|
direction: 'top',
|
|
|
|
|
offset: [0, -8]
|
|
|
|
|
})
|
|
|
|
|
layer.on('click', () => onZoneClick?.(props))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
geoJsonLayer.addTo(map)
|
|
|
|
|
zonesLayerRef.current = geoJsonLayer
|
|
|
|
|
|
|
|
|
|
let idx = 0
|
|
|
|
|
geoJsonLayer.eachLayer((layer: L.Layer) => {
|
|
|
|
|
const leafLayer = layer as L.Polygon
|
|
|
|
|
leafLayer.setStyle({ fillOpacity: 0 })
|
|
|
|
|
const index = idx++
|
|
|
|
|
const targetOpacity = 0.5
|
|
|
|
|
const delay = Math.min(index * 30, 600)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
leafLayer.setStyle({ fillOpacity: targetOpacity })
|
|
|
|
|
}, delay)
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
[activeLayer, onZoneClick]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined' || !mapRef.current) return
|
|
|
|
|
|
|
|
|
|
let isMounted = true
|
|
|
|
|
let cleanupFn: (() => void) | null = null
|
|
|
|
|
|
|
|
|
|
const initMap = async () => {
|
|
|
|
|
const L = (await import('leaflet')).default
|
|
|
|
|
await import('leaflet-draw')
|
|
|
|
|
;(window as unknown as { L: typeof L }).L = L
|
|
|
|
|
|
|
|
|
|
if (!isMounted || !mapRef.current || mapInstanceRef.current) return
|
|
|
|
|
|
|
|
|
|
const map = L.map(mapRef.current).setView(center, zoom)
|
|
|
|
|
|
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
|
|
|
attribution: '© OpenStreetMap'
|
|
|
|
|
}).addTo(map)
|
|
|
|
|
|
|
|
|
|
const drawnItems = L.featureGroup().addTo(map)
|
|
|
|
|
drawnItemsRef.current = drawnItems
|
|
|
|
|
|
2026-02-21 22:05:47 +03:30
|
|
|
if (!readOnly) {
|
|
|
|
|
const drawControl = new L.Control.Draw({
|
|
|
|
|
position: 'topright',
|
|
|
|
|
draw: {
|
|
|
|
|
polygon: { shapeOptions: { color: '#3388ff' } },
|
|
|
|
|
rectangle: { shapeOptions: { color: '#3388ff' } },
|
|
|
|
|
circle: false,
|
|
|
|
|
circlemarker: false,
|
|
|
|
|
marker: false,
|
|
|
|
|
polyline: false
|
|
|
|
|
},
|
|
|
|
|
edit: { featureGroup: drawnItems, remove: true }
|
|
|
|
|
})
|
|
|
|
|
map.addControl(drawControl)
|
|
|
|
|
drawControlRef.current = drawControl
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
const getGeoJsonFromDrawn = (): MapDrawGeoJSON => {
|
|
|
|
|
const geojson = drawnItems.toGeoJSON()
|
|
|
|
|
if (geojson.type === 'FeatureCollection' && geojson.features.length > 0) {
|
|
|
|
|
return geojson.features[0] as unknown as MapDrawGeoJSON
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const emitAndRender = () => {
|
|
|
|
|
const geojson = getGeoJsonFromDrawn()
|
|
|
|
|
onAreaChange?.(geojson)
|
|
|
|
|
if (geojson && geojson.geometry && (geojson.geometry as { type: string }).type === 'Polygon') {
|
|
|
|
|
renderZones(map, geojson as Feature<Polygon>)
|
|
|
|
|
} else if (zonesLayerRef.current) {
|
|
|
|
|
map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 22:05:47 +03:30
|
|
|
if (readOnly && initialAreaGeoJson && initialAreaGeoJson.geometry && (initialAreaGeoJson.geometry as { type: string }).type === 'Polygon') {
|
|
|
|
|
drawnItems.clearLayers()
|
|
|
|
|
L.geoJSON(initialAreaGeoJson as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
|
|
|
|
|
emitAndRender()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 22:15:34 +03:30
|
|
|
const onCreated = (e: L.LeafletEvent) => {
|
|
|
|
|
const event = e as L.DrawEvents.Created
|
|
|
|
|
drawnItems.clearLayers()
|
|
|
|
|
drawnItems.addLayer(event.layer)
|
|
|
|
|
emitAndRender()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onEdited = () => emitAndRender()
|
|
|
|
|
const onDeleted = () => {
|
|
|
|
|
onAreaChange?.(null)
|
|
|
|
|
if (zonesLayerRef.current) {
|
|
|
|
|
map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 22:05:47 +03:30
|
|
|
if (!readOnly) {
|
|
|
|
|
map.on(L.Draw.Event.CREATED, onCreated)
|
|
|
|
|
map.on(L.Draw.Event.EDITED, onEdited)
|
|
|
|
|
map.on(L.Draw.Event.DELETED, onDeleted)
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
mapInstanceRef.current = map
|
|
|
|
|
|
|
|
|
|
cleanupFn = () => {
|
2026-02-21 22:05:47 +03:30
|
|
|
if (!readOnly) {
|
|
|
|
|
map.off(L.Draw.Event.CREATED, onCreated)
|
|
|
|
|
map.off(L.Draw.Event.EDITED, onEdited)
|
|
|
|
|
map.off(L.Draw.Event.DELETED, onDeleted)
|
|
|
|
|
if (drawControlRef.current) {
|
|
|
|
|
map.removeControl(drawControlRef.current)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
if (zonesLayerRef.current) map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
map.remove()
|
|
|
|
|
mapInstanceRef.current = null
|
|
|
|
|
drawnItemsRef.current = null
|
|
|
|
|
drawControlRef.current = null
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initMap()
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false
|
|
|
|
|
if (cleanupFn) cleanupFn()
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!mapInstanceRef.current || !drawnItemsRef.current || optimizationKey === 0) return
|
|
|
|
|
const geojson = drawnItemsRef.current.toGeoJSON()
|
|
|
|
|
if (geojson.type === 'FeatureCollection' && geojson.features.length > 0) {
|
|
|
|
|
const feature = geojson.features[0] as Feature<Polygon>
|
|
|
|
|
renderZones(mapInstanceRef.current, feature)
|
|
|
|
|
}
|
|
|
|
|
}, [optimizationKey, activeLayer, renderZones])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const zonesLayer = zonesLayerRef.current
|
|
|
|
|
if (!zonesLayer || !drawnItemsRef.current) return
|
|
|
|
|
const geojson = drawnItemsRef.current.toGeoJSON()
|
|
|
|
|
if (geojson.type !== 'FeatureCollection' || geojson.features.length === 0) return
|
|
|
|
|
zonesLayer.eachLayer((layer: L.Layer) => {
|
|
|
|
|
const leafLayer = layer as L.Polygon & { feature?: { properties: ZoneFeatureProperties } }
|
|
|
|
|
const props = leafLayer.feature?.properties
|
|
|
|
|
if (!props) return
|
|
|
|
|
leafLayer.setStyle({
|
|
|
|
|
fillColor: activeLayer === 'crops' ? CROP_COLORS[props.crop as CropType] : '#94a3b8',
|
|
|
|
|
fillOpacity: 0.5,
|
|
|
|
|
weight: 1,
|
|
|
|
|
color: '#fff'
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}, [activeLayer])
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={mapRef}
|
|
|
|
|
style={{
|
|
|
|
|
height: typeof height === 'number' ? `${height}px` : height,
|
|
|
|
|
width: '100%',
|
|
|
|
|
borderRadius: 12
|
|
|
|
|
}}
|
|
|
|
|
className={`overflow-hidden ${className}`}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|