Files
Frontend/src/views/dashboards/farm/cropZoning/CropZoningMap.tsx
T

261 lines
8.2 KiB
TypeScript
Raw Normal View History

'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 type { LayerType } from './cropZoningTypes'
import type { ZoneMapData, ZoneInitialData } from '@/libs/api/services/cropZoningService'
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: ZoneInitialData) => void
optimizationKey?: number
className?: string
/** منطقهٔ اولیه؛ وقتی مقدار دارد و readOnly باشد نقشه فقط نمایشی است */
initialAreaGeoJson?: MapDrawGeoJSON | null
/** دیتای زون‌ها برای نقشه — از APIهای zones/initial یا water-need یا soil-quality یا cultivation-risk */
zonesData?: ZoneMapData[] | null
/** لیبل محصولات از API (id -> label) — فقط برای لایهٔ crops */
productLabels?: Record<string, string>
/** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */
readOnly?: boolean
}
export default function CropZoningMap({
center = [35.6892, 51.389],
zoom = 13,
height = '100%',
activeLayer,
onAreaChange,
onZoneClick,
optimizationKey = 0,
className = '',
initialAreaGeoJson = null,
zonesData = null,
productLabels = {},
readOnly = false
}: 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 renderZonesFromApi = useCallback(
(map: L.Map, zones: ZoneMapData[]) => {
if (zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
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,
color: z.color,
tooltipContent: z.tooltipContent,
cultivable: z.cultivable,
zoneInitialData: z.zoneInitialData
}
}))
}
const L = (window as unknown as { L: typeof import('leaflet') }).L
if (!L) return
const geoJsonLayer = L.geoJSON(grid as never, {
style: (feature?: { properties?: { color?: string } }) => {
const color = feature?.properties?.color ?? '#94a3b8'
return {
fillColor: color,
fillOpacity: 0.5,
weight: 1,
color: '#fff'
}
},
onEachFeature: (feat: unknown, leafLayer: L.Layer) => {
const f = feat as { geometry?: Polygon; properties?: Record<string, unknown> }
const props = f?.properties
const geom = f?.geometry
if (!props || !geom) return
const layer = leafLayer as L.Polygon
const tooltipContent = (props.tooltipContent as string) ?? ''
const cultivable = props.cultivable === true
const zoneInitialData = props.zoneInitialData as ZoneInitialData | undefined
layer.bindTooltip(tooltipContent, {
sticky: true,
className: 'zone-tooltip',
direction: 'top',
offset: [0, -8]
})
layer.on('click', () => cultivable && zoneInitialData && onZoneClick?.(zoneInitialData))
}
})
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)
})
},
[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
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
}
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 emitAreaChange = () => {
const geojson = getGeoJsonFromDrawn()
onAreaChange?.(geojson)
if (!geojson && zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}
if (readOnly && initialAreaGeoJson && initialAreaGeoJson.geometry && (initialAreaGeoJson.geometry as { type: string }).type === 'Polygon') {
drawnItems.clearLayers()
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
emitAreaChange()
}
const onCreated = (e: L.LeafletEvent) => {
const event = e as L.DrawEvents.Created
drawnItems.clearLayers()
drawnItems.addLayer(event.layer)
emitAreaChange()
}
const onEdited = () => emitAreaChange()
const onDeleted = () => {
onAreaChange?.(null)
if (zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}
if (!readOnly) {
map.on(L.Draw.Event.CREATED, onCreated)
map.on(L.Draw.Event.EDITED, onEdited)
map.on(L.Draw.Event.DELETED, onDeleted)
}
mapInstanceRef.current = map
cleanupFn = () => {
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)
}
}
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) return
if (zonesData && zonesData.length > 0) {
renderZonesFromApi(mapInstanceRef.current, zonesData)
} else if (zonesLayerRef.current) {
mapInstanceRef.current.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}, [zonesData, optimizationKey, renderZonesFromApi])
return (
<div
ref={mapRef}
style={{
height: typeof height === 'number' ? `${height}px` : height,
width: '100%',
borderRadius: 12
}}
className={`overflow-hidden ${className}`}
/>
)
}