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'
|
2026-02-26 00:37:00 +03:30
|
|
|
import type { LayerType } from './cropZoningTypes'
|
|
|
|
|
import type { ZoneMapData, ZoneInitialData } from '@/libs/api/services/cropZoningService'
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
export type MapDrawGeoJSON = Record<string, unknown> | null
|
|
|
|
|
|
|
|
|
|
type CropZoningMapProps = {
|
|
|
|
|
center?: [number, number]
|
|
|
|
|
zoom?: number
|
|
|
|
|
height?: string | number
|
|
|
|
|
activeLayer: LayerType
|
|
|
|
|
onAreaChange?: (geojson: MapDrawGeoJSON) => void
|
2026-02-26 00:17:32 +03:30
|
|
|
onZoneClick?: (zone: ZoneInitialData) => void
|
2026-02-20 22:15:34 +03:30
|
|
|
optimizationKey?: number
|
|
|
|
|
className?: string
|
2026-02-26 00:17:32 +03:30
|
|
|
/** منطقهٔ اولیه؛ وقتی مقدار دارد و readOnly باشد نقشه فقط نمایشی است */
|
2026-02-21 22:05:47 +03:30
|
|
|
initialAreaGeoJson?: MapDrawGeoJSON | null
|
2026-02-26 00:37:00 +03:30
|
|
|
/** دیتای زونها برای نقشه — از APIهای zones/initial یا water-need یا soil-quality یا cultivation-risk */
|
|
|
|
|
zonesData?: ZoneMapData[] | null
|
|
|
|
|
/** لیبل محصولات از API (id -> label) — فقط برای لایهٔ crops */
|
2026-02-26 00:17:32 +03:30
|
|
|
productLabels?: Record<string, string>
|
2026-02-21 22:05:47 +03:30
|
|
|
/** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */
|
|
|
|
|
readOnly?: boolean
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
|
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,
|
2026-02-26 00:17:32 +03:30
|
|
|
zonesData = null,
|
2026-02-26 00:37:00 +03:30
|
|
|
productLabels = {},
|
2026-02-21 22:05:47 +03:30
|
|
|
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)
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
const renderZonesFromApi = useCallback(
|
2026-02-26 00:37:00 +03:30
|
|
|
(map: L.Map, zones: ZoneMapData[]) => {
|
2026-02-20 22:15:34 +03:30
|
|
|
if (zonesLayerRef.current) {
|
|
|
|
|
map.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
|
|
|
|
}
|
2026-02-26 00:17:32 +03:30
|
|
|
if (zones.length === 0) return
|
|
|
|
|
|
|
|
|
|
const grid = {
|
|
|
|
|
type: 'FeatureCollection' as const,
|
|
|
|
|
features: zones.map(z => ({
|
|
|
|
|
type: 'Feature' as const,
|
|
|
|
|
geometry: z.geometry,
|
|
|
|
|
properties: {
|
|
|
|
|
zoneId: z.zoneId,
|
2026-02-26 00:37:00 +03:30
|
|
|
color: z.color,
|
|
|
|
|
tooltipContent: z.tooltipContent,
|
|
|
|
|
cultivable: z.cultivable,
|
|
|
|
|
zoneInitialData: z.zoneInitialData
|
2026-02-26 00:17:32 +03:30
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
}
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
const L = (window as unknown as { L: typeof import('leaflet') }).L
|
|
|
|
|
if (!L) return
|
|
|
|
|
|
|
|
|
|
const geoJsonLayer = L.geoJSON(grid as never, {
|
2026-02-26 00:37:00 +03:30
|
|
|
style: (feature?: { properties?: { color?: string } }) => {
|
|
|
|
|
const color = feature?.properties?.color ?? '#94a3b8'
|
2026-02-20 22:15:34 +03:30
|
|
|
return {
|
2026-02-26 00:37:00 +03:30
|
|
|
fillColor: color,
|
2026-02-20 22:15:34 +03:30
|
|
|
fillOpacity: 0.5,
|
|
|
|
|
weight: 1,
|
|
|
|
|
color: '#fff'
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-02-26 00:37:00 +03:30
|
|
|
onEachFeature: (feat: unknown, leafLayer: L.Layer) => {
|
|
|
|
|
const f = feat as { geometry?: Polygon; properties?: Record<string, unknown> }
|
|
|
|
|
const props = f?.properties
|
|
|
|
|
const geom = f?.geometry
|
2026-02-26 00:17:32 +03:30
|
|
|
if (!props || !geom) return
|
2026-02-20 22:15:34 +03:30
|
|
|
const layer = leafLayer as L.Polygon
|
2026-02-26 00:37:00 +03:30
|
|
|
const tooltipContent = (props.tooltipContent as string) ?? ''
|
|
|
|
|
const cultivable = props.cultivable === true
|
|
|
|
|
const zoneInitialData = props.zoneInitialData as ZoneInitialData | undefined
|
2026-02-20 22:15:34 +03:30
|
|
|
layer.bindTooltip(tooltipContent, {
|
|
|
|
|
sticky: true,
|
|
|
|
|
className: 'zone-tooltip',
|
|
|
|
|
direction: 'top',
|
|
|
|
|
offset: [0, -8]
|
|
|
|
|
})
|
2026-02-26 00:37:00 +03:30
|
|
|
layer.on('click', () => cultivable && zoneInitialData && onZoneClick?.(zoneInitialData))
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
geoJsonLayer.addTo(map)
|
|
|
|
|
zonesLayerRef.current = geoJsonLayer
|
|
|
|
|
|
2026-04-01 17:28:05 +03:30
|
|
|
const bounds = geoJsonLayer.getBounds()
|
|
|
|
|
if (bounds.isValid()) {
|
|
|
|
|
map.fitBounds(bounds, { padding: [24, 24] })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 22:15:34 +03:30
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
},
|
2026-02-26 00:37:00 +03:30
|
|
|
[onZoneClick]
|
2026-02-20 22:15:34 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
const emitAreaChange = () => {
|
2026-02-20 22:15:34 +03:30
|
|
|
const geojson = getGeoJsonFromDrawn()
|
|
|
|
|
onAreaChange?.(geojson)
|
2026-02-26 00:17:32 +03:30
|
|
|
if (!geojson && zonesLayerRef.current) {
|
2026-02-20 22:15:34 +03:30
|
|
|
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()
|
2026-02-26 00:17:32 +03:30
|
|
|
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
|
|
|
|
|
emitAreaChange()
|
2026-04-01 17:28:05 +03:30
|
|
|
const bounds = drawnItems.getBounds()
|
|
|
|
|
if (bounds.isValid()) {
|
|
|
|
|
map.fitBounds(bounds, { padding: [24, 24] })
|
|
|
|
|
}
|
2026-02-21 22:05:47 +03:30
|
|
|
}
|
|
|
|
|
|
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)
|
2026-02-26 00:17:32 +03:30
|
|
|
emitAreaChange()
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
|
|
|
|
|
2026-02-26 00:17:32 +03:30
|
|
|
const onEdited = () => emitAreaChange()
|
2026-02-20 22:15:34 +03:30
|
|
|
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(() => {
|
2026-02-26 00:17:32 +03:30
|
|
|
if (!mapInstanceRef.current) return
|
|
|
|
|
if (zonesData && zonesData.length > 0) {
|
|
|
|
|
renderZonesFromApi(mapInstanceRef.current, zonesData)
|
|
|
|
|
} else if (zonesLayerRef.current) {
|
|
|
|
|
mapInstanceRef.current.removeLayer(zonesLayerRef.current)
|
|
|
|
|
zonesLayerRef.current = null
|
2026-02-20 22:15:34 +03:30
|
|
|
}
|
2026-02-26 00:17:32 +03:30
|
|
|
}, [zonesData, optimizationKey, renderZonesFromApi])
|
2026-02-20 22:15:34 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={mapRef}
|
|
|
|
|
style={{
|
|
|
|
|
height: typeof height === 'number' ? `${height}px` : height,
|
|
|
|
|
width: '100%',
|
|
|
|
|
borderRadius: 12
|
|
|
|
|
}}
|
|
|
|
|
className={`overflow-hidden ${className}`}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|