Files
universal_is/client/src/components/map/MapComponent.tsx

588 lines
23 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import 'ol/ol.css'
import { Modify } from 'ol/interaction'
import { ImageStatic, Vector as VectorSource } from 'ol/source'
import Feature from 'ol/Feature'
import { getCenter } from 'ol/extent'
import { highlightStyleRed, highlightStyleYellow } from './MapStyles'
import { customMapSource, googleMapsSatelliteSource, yandexMapsSatelliteSource } from './MapSources'
import { Geometry } from 'ol/geom'
import { fromExtent } from 'ol/geom/Polygon'
import { Coordinate } from 'ol/coordinate'
import { addFigures, addInteractions, addLines, getCitySettings, getFeatureByEntityId, handleImageDrop, loadFeatures, loadRegionBounds, loadRegionCapitals, zoomToFeature } from './mapUtils'
import useSWR, { SWRConfiguration } from 'swr'
import { fetcher } from '../../http/axiosInstance'
import { BASE_URL } from '../../constants'
import { IconBoxPadding, IconChevronLeft, } from '@tabler/icons-react'
import axios from 'axios'
import MapToolbar from './MapToolbar/MapToolbar'
import MapStatusbar from './MapStatusbar/MapStatusbar'
import { setAlignMode, setTypeRoles, useMapStore, setMapLabel, } from '../../store/map'
import ObjectTree from '../Tree/ObjectTree'
import { setSelectedDistrict, setSelectedRegion, setSelectedYear, useObjectsStore } from '../../store/objects'
import ObjectParameters from './ObjectParameters/ObjectParameters'
import TabsPane, { ITabsPane } from './TabsPane/TabsPane'
//import { useSearchParams } from 'react-router-dom'
import GeoJSON from 'ol/format/GeoJSON'
import MapLegend from './MapLegend/MapLegend'
import MapMode from './MapMode'
import MapPrint from './MapPrint/MapPrint'
import { Button, Divider, Spinner, Portal, Tooltip, Drawer } from '@fluentui/react-components'
import { useAppStore } from '../../store/app'
import { setDistrictsData, setRegionsData } from '../../store/regions'
import View from 'ol/View'
import { Style } from 'ol/style'
import MapRegionSelect from './MapRegionSelect/MapRegionSelect'
import MapLayersSelect from './MapLayers/MapLayersSelect'
import MapObjectSearch from './MapObjectSearch/MapObjectSearch'
import { useCapitals } from '../../hooks/map/useCapitals'
const swrOptions: SWRConfiguration = {
revalidateOnFocus: false
}
const MapComponent = ({
id,
active = false
}: {
id: string,
active: boolean,
}) => {
const { colorScheme } = useAppStore()
// Store
const { selectedYear, currentObjectId, selectedRegion, selectedDistrict } = useObjectsStore().id[id]
const {
mode, map, currentTool, alignMode, satMapsProvider,
selectedObjectType, measureDraw, draw, snap, translate,
drawingLayerSource,
satLayer, staticMapLayer, figuresLayer, linesLayer,
regionsLayer, districtBoundLayer, districtsLayer,
printAreaDraw, statusText, statusTextPosition, regionSelect, districtSelect,
capitalsLayer
} = useMapStore().id[id]
// Tab settings
const objectsPane: ITabsPane[] = [
{ title: 'Объекты', value: 'objects', view: <ObjectTree map_id={id} /> },
{ title: 'Неразмещенные', value: 'unplaced', view: <></> },
{ title: 'Другие', value: 'other', view: <></> },
]
// const paramsPane: ITabsPane[] = [
// { title: 'История изменений', value: 'history', view: <></> },
// { title: 'Параметры', value: 'parameters', view: <ObjectParameters map_id={id} /> },
// { title: 'Вычисляемые', value: 'calculated', view: <></> }
// ]
// Map
const mapElement = useRef<HTMLDivElement | null>(null)
// Get type roles
useSWR(`/gis/type-roles`, (url) => fetcher(url, BASE_URL.ems).then(res => {
if (Array.isArray(res)) {
setTypeRoles(id, res)
}
return res
}), swrOptions)
const { data: regionsData } = useSWR(`/general/regions`, (url) => fetcher(url, BASE_URL.ems).then(res => {
setRegionsData(res)
return res
}), swrOptions)
// Bounds: region
const { data: regionBoundsData } = useSWR(`/gis/bounds/region`, (url) => fetcher(url, BASE_URL.ems), swrOptions)
// Map init
useEffect(() => {
map?.setTarget(mapElement.current as HTMLDivElement)
if (drawingLayerSource) {
const modify = new Modify({ source: drawingLayerSource })
map?.addInteraction(modify)
}
loadFeatures(id)
}, [])
// First step: On region bounds loaded
useEffect(() => {
if (regionsLayer && regionBoundsData) {
loadRegionBounds(regionBoundsData, regionsLayer)
}
return () => {
if (regionsLayer) {
regionsLayer.getSource()?.clear()
}
}
}, [regionBoundsData])
useEffect(() => {
if (regionsData && Array.isArray(regionsData) && capitalsLayer) {
loadRegionCapitals(regionsData, capitalsLayer)
}
return () => {
if (capitalsLayer) {
capitalsLayer.getSource()?.clear()
}
}
}, [regionsData, capitalsLayer])
useEffect(() => {
if (selectedRegion === null && regionBoundsData) {
if (map) {
const extent = regionsLayer.getSource()?.getExtent()
if (extent) {
map?.setView(new View({
extent: extent,
showFullExtent: true,
}))
map.getView().fit(fromExtent(extent), { padding: [100, 100, 100, 100] })
}
regionsLayer.getSource()?.forEachFeature((feature) => {
if (feature.getProperties()['entity_id'] !== selectedRegion) {
feature.setStyle()
}
})
map.addInteraction(regionSelect)
}
}
}, [regionBoundsData, selectedRegion, map, regionsLayer])
useEffect(() => {
if (selectedRegion && map) {
regionsLayer.getSource()?.forEachFeature((feature) => {
if (feature.getProperties()['entity_id'] !== selectedRegion) {
feature.setStyle(new Style())
}
})
}
}, [selectedRegion, map])
// Last step: once selected scheme
useEffect(() => {
if (selectedDistrict && selectedYear && districtBoundLayer) {
const bounds = new VectorSource({
url: `${BASE_URL.ems}/gis/bounds/city/${selectedDistrict}`,
format: new GeoJSON(),
})
districtBoundLayer.setSource(bounds)
//
// bounds.on('featuresloadend', function () {
// map?.setView(new View({
// extent: bounds.getExtent()
// }))
// })
}
return () => {
if (districtBoundLayer) {
districtBoundLayer.getSource()?.clear()
}
}
}, [selectedDistrict, selectedYear, districtBoundLayer])
// Edit Mode: add interaction on tool change
useEffect(() => {
if (currentTool) {
if (draw) map?.removeInteraction(draw)
//if (snap.current) map?.current?.removeInteraction(snap.current)
addInteractions(id)
} else {
//map?.getInteractions().clear()
//addInteractions(id)
if (translate) map?.removeInteraction(translate)
if (draw) map?.removeInteraction(draw)
if (snap) map?.removeInteraction(snap)
if (measureDraw) map?.removeInteraction(measureDraw)
}
}, [currentTool])
// Satellite tiles setting
useEffect(() => {
if (satLayer) {
if (satMapsProvider === 'google') {
satLayer.setSource(googleMapsSatelliteSource)
}
if (satMapsProvider === 'yandex') {
satLayer.setSource(yandexMapsSatelliteSource)
}
if (satMapsProvider === 'custom') {
satLayer.setSource(customMapSource)
}
}
}, [satMapsProvider, satLayer])
useEffect(() => {
if (!selectedObjectType || !map) return
if (figuresLayer) {
// Reset styles and apply highlight to matching features
figuresLayer.getSource()?.getFeatures().forEach((feature) => {
if (selectedObjectType == feature.get('type')) {
feature.setStyle(highlightStyleYellow)
} else {
feature.setStyle(undefined) // Reset to default style
}
})
}
if (linesLayer) {
// Reset styles and apply highlight to matching features
linesLayer.getSource()?.getFeatures().forEach((feature) => {
if (selectedObjectType == feature.get('type')) {
feature.setStyle(highlightStyleYellow)
} else {
feature.setStyle(undefined) // Reset to default style
}
})
}
}, [selectedObjectType])
useEffect(() => {
if (currentObjectId) {
if (figuresLayer) {
// Reset styles and apply highlight to matching features
figuresLayer.getSource()?.getFeatures().forEach((feature: Feature) => {
if (Array.isArray(feature.get('object_id')) ? feature.get('object_id')[0] === currentObjectId : currentObjectId === feature.get('object_id')) {
feature.setStyle(highlightStyleRed)
zoomToFeature(id, feature)
} else {
feature.setStyle(undefined) // Reset to default style
}
})
}
if (linesLayer) {
// Reset styles and apply highlight to matching features
linesLayer.getSource()?.getFeatures().forEach((feature: Feature) => {
if (Array.isArray(feature.get('object_id')) ? feature.get('object_id')[0] === currentObjectId : currentObjectId === feature.get('object_id')) {
feature.setStyle(highlightStyleRed)
zoomToFeature(id, feature)
} else {
feature.setStyle(undefined) // Reset to default style
}
})
}
}
}, [currentObjectId, figuresLayer, linesLayer])
const { data: districtsData } = useSWR(selectedRegion ? `/general/districts/?region_id=${selectedRegion}` : null, (url) => fetcher(url, BASE_URL.ems).then(res => {
setDistrictsData(res)
return res
}), swrOptions)
const { data: figuresData, isValidating: figuresValidating } = useSWR(
selectedDistrict && selectedYear ? `/gis/figures/all?city_id=${selectedDistrict}&year=${selectedYear}&offset=0&limit=${10000}` : null,
(url) => axios.get(url, {
baseURL: BASE_URL.ems
}).then((res) => res.data),
swrOptions
)
const { data: linesData, isValidating: linesValidating } = useSWR(
!figuresValidating && selectedDistrict && selectedYear ? `/gis/lines/all?city_id=${selectedDistrict}&year=${selectedYear}&offset=0&limit=${10000}` : null,
(url) => axios.get(url, {
baseURL: BASE_URL.ems
}).then((res) => res.data),
swrOptions
)
const { data: districtData } = useSWR(
selectedDistrict ? `/gis/images/all?city_id=${selectedDistrict}` : null,
(url) => axios.get(url, {
baseURL: BASE_URL.ems
}).then((res) => Array.isArray(res.data) ? res.data[0] : null),
swrOptions
)
useEffect(() => {
if (Array.isArray(districtsData)) {
districtsData.map((district) => {
if (district.id === selectedDistrict) {
setMapLabel(id, [district.name, district.district_name, selectedYear].join(' - '))
}
})
}
}, [districtsData, id, selectedDistrict, selectedYear])
useEffect(() => {
if (selectedDistrict === null) {
setSelectedYear(id, null)
map?.addInteraction(districtSelect)
}
if (selectedRegion === null) {
setSelectedYear(id, null)
setSelectedDistrict(id, null)
}
}, [selectedDistrict, selectedRegion, id, map])
const [leftPaneHidden, setLeftPaneHidden] = useState(false)
useEffect(() => {
const districtBoundSource = districtBoundLayer.getSource()
if (map && selectedDistrict && districtBoundSource && districtBoundSource.getFeatures().length > 0) {
const center: Coordinate = getCenter(districtBoundSource.getExtent())
const settings = getCitySettings()
addFigures(center, figuresData, figuresLayer, settings, map)
addLines(center, linesData, linesLayer, settings)
}
}, [map, figuresData, linesData, selectedDistrict, selectedYear, districtBoundLayer])
useEffect(() => {
if (selectedDistrict && districtData) {
const settings = getCitySettings()
const imageUrl = `${import.meta.env.VITE_API_EMS_URL}/tiles/static/${selectedDistrict}`
const img = new Image()
img.src = imageUrl
img.onload = () => {
if (map) {
const width = img.naturalWidth
const height = img.naturalHeight
//const k = (width < height ? width / height : height / width)
const k = settings.image_scale
const wk = width * k
const hk = height * k
const center = [settings.offset_x + (wk), settings.offset_y - (hk)]
const extent = [center[0] - (wk), center[1] - (hk), center[0] + (wk), center[1] + (hk)]
// Set up the initial image layer with the extent
const imageSource = new ImageStatic({
url: imageUrl,
imageExtent: extent,
})
staticMapLayer.setSource(imageSource)
//map.current.addLayer(imageLayer.current)
}
}
}
}, [selectedDistrict, districtData, staticMapLayer])
useEffect(() => {
if (map) {
if (mode === 'print') {
map.addInteraction(printAreaDraw)
} else {
map.removeInteraction(printAreaDraw)
}
}
}, [mode, map, printAreaDraw])
useEffect(() => {
if (districtsData && Array.isArray(districtsData)) {
const list: Number[] = []
districtsData.map(district => {
list.push(district.id as Number)
})
fetch(`${BASE_URL.ems}/gis/bounds/city`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ list })
}).then(async response => {
const data = await response.json()
if (Array.isArray(data)) {
data.map(bound => {
const geoJson = new GeoJSON() //new GeoJSON({ featureProjection: 'EPSG:3857' })
const geometry = geoJson.readGeometry(bound) as Geometry
const feature = new Feature(geometry)
feature.setProperties(bound.properties)
districtsLayer.getSource()?.addFeature(feature)
})
}
})
}
}, [districtsData])
const mapTooltipRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (mapTooltipRef.current) {
const leftOffset = 30
const topOffset = -30
mapTooltipRef.current.style.left = (statusTextPosition[0] + leftOffset).toString() + 'px'
mapTooltipRef.current.style.top = (statusTextPosition[1] + topOffset).toString() + 'px'
}
}, [statusTextPosition, mapTooltipRef])
// zoom on region select
useEffect(() => {
if (selectedRegion && !selectedDistrict) {
const feature = getFeatureByEntityId(selectedRegion, regionsLayer)
if (feature) {
regionSelect.getFeatures().push(feature)
map?.setView(new View({
extent: feature?.getGeometry()?.getExtent(),
showFullExtent: true,
}))
}
zoomToFeature(id, feature)
} else if (selectedDistrict) {
// zoom on district select
const feature = getFeatureByEntityId(selectedDistrict, districtsLayer)
if (feature) {
districtSelect.getFeatures().push(feature)
regionsLayer.setOpacity(0)
}
// map?.setView(new View({
// extent: feature?.getGeometry()?.getExtent(),
// showFullExtent: true,
// }))
zoomToFeature(id, feature)
} else if (!selectedRegion) {
setSelectedRegion(id, null)
setSelectedYear(id, null)
}
}, [selectedRegion, selectedDistrict, id])
useCapitals(capitalsLayer, !!selectedDistrict)
useEffect(() => {
if (selectedYear) {
regionsLayer.setOpacity(0)
districtsLayer.setOpacity(0)
}
}, [selectedYear])
return (
<div style={{ display: 'grid', gridTemplateRows: 'auto min-content', position: 'relative', width: '100%', height: '100%' }}>
<MapPrint id={id} mapElement={mapElement} />
{active &&
<Portal mountNode={document.querySelector('#header-portal')}>
<div style={{ display: 'flex', gap: '1rem' }}>
<MapObjectSearch map_id={id} />
<Button icon={<IconBoxPadding />} appearance={alignMode ? 'primary' : 'transparent'} onClick={() => setAlignMode(id, !alignMode)} />
<MapLayersSelect map_id={id} />
</div>
</Portal >
}
<div style={{ gridRow: '1 / span 1', display: 'grid', gridTemplateColumns: 'min-content auto', position: 'relative' }}>
<div style={{ gridColumn: '1 / span 1', width: !selectedRegion || (!!selectedRegion && !selectedYear) ? '300px' : 'min-content', height: '100%', position: 'relative', display: 'flex', zIndex: '2' }}>
<MapRegionSelect map_id={id} />
</div>
<div ref={mapElement} id={id} key={id} style={{ gridColumn: '2 / span 1', position: 'relative', width: '100%', height: '100%', maxHeight: '100%', zIndex: '1', filter: colorScheme === 'dark' ? 'invert(100%) hue-rotate(180deg)' : 'unset' }} onDragOver={(e) => e.preventDefault()} onDrop={(e) => handleImageDrop(e, id)}>
<div>
{statusText && active && !selectedYear &&
<Tooltip hideDelay={0} showDelay={0} content={statusText} relationship='description' visible>
<div style={{ position: 'absolute', zIndex: 9999, userSelect: 'none', pointerEvents: 'none' }} ref={mapTooltipRef}>
{/* {statusText} */}
</div>
</Tooltip>
}
</div>
</div>
<div style={{ position: 'absolute', display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }}>
<div style={{ display: 'flex', height: '94%', padding: '0.5rem', flexGrow: 1 }}>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '380px' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', gap: '0.5rem' }}>
<Drawer style={{ borderRadius: '0.25rem', height: '100%', zIndex: 1 }} type='inline' open={!!selectedRegion && !!selectedDistrict && !!selectedYear && !leftPaneHidden}>
<TabsPane defaultTab='objects' tabs={objectsPane} />
<Divider />
<ObjectParameters map_id={id} />
{/* <TabsPane defaultTab='parameters' tabs={paramsPane} /> */}
</Drawer>
{!!selectedRegion && !!selectedDistrict && !!selectedYear &&
<Button
icon={<IconChevronLeft size={16}
style={{
transform: `${leftPaneHidden ? 'rotate(180deg)' : ''}`,
}} />}
style={{
zIndex: '1',
display: 'flex',
height: 'min-content'
}}
appearance='subtle'
onClick={() => setLeftPaneHidden(!leftPaneHidden)}
/>
}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center' }} >
<div style={{ display: 'flex', flexDirection: 'column', width: 'fit-content' }}>
{selectedDistrict && selectedYear && <MapMode map_id={id} />}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '340px', alignItems: 'flex-end', justifyContent: 'space-between', gap: '1rem' }}>
<MapToolbar map_id={id} />
{!!selectedRegion && !!selectedDistrict && !!selectedYear &&
<MapLegend selectedDistrict={selectedDistrict} selectedYear={selectedYear} />
}
</div>
</div>
</div>
</div>
{(linesValidating || figuresValidating) && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "rgba(255, 255, 255, 0.6)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 9999,
}}
>
<Spinner size="large" label="Загрузка..." />
</div>
)}
<div style={{ gridRow: '2 / span 1', display: 'flex', bottom: '0', width: '100%' }}>
<MapStatusbar
map_id={id}
/>
</div>
</div>
)
}
export default MapComponent