Files
tests/client/src/components/map/MapComponent.tsx
cracklesparkle 3994989994 Map update
2024-09-05 17:14:48 +09:00

767 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react'
import GeoJSON from 'ol/format/GeoJSON'
import 'ol/ol.css'
import Map from 'ol/Map'
import View from 'ol/View'
import { Draw, Modify, Select, Snap, Translate } from 'ol/interaction'
import { ImageStatic, OSM, Vector as VectorSource, XYZ } from 'ol/source'
import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'
import { Divider, IconButton, Slider, Stack, Select as MUISelect, MenuItem, Box } from '@mui/material'
import { Add, Adjust, Api, CircleOutlined, OpenWith, RectangleOutlined, Straighten, Timeline, Undo, Warning } from '@mui/icons-material'
import { Type } from 'ol/geom/Geometry'
import { click, never, noModifierKeys, platformModifierKeyOnly, primaryAction, shiftKeyOnly } from 'ol/events/condition'
import Feature from 'ol/Feature'
import { SatelliteMapsProvider } from '../../interfaces/map'
import { boundingExtent, containsExtent, Extent, getBottomLeft, getBottomRight, getCenter, getHeight, getTopLeft, getTopRight, getWidth } from 'ol/extent'
import { drawingLayerStyle, regionsLayerStyle, selectStyle } from './MapStyles'
import { googleMapsSatelliteSource, regionsLayerSource, yandexMapsSatelliteSource } from './MapSources'
import { mapCenter } from './MapConstants'
import ImageLayer from 'ol/layer/Image'
import VectorImageLayer from 'ol/layer/VectorImage'
import { LineString, MultiPoint, Point, Polygon, SimpleGeometry } from 'ol/geom'
import { fromExtent } from 'ol/geom/Polygon'
import Collection from 'ol/Collection'
import { Coordinate, distance, rotate } from 'ol/coordinate'
import { Stroke, Fill, Circle as CircleStyle, Style } from 'ol/style'
import { addCoordinateTransforms, addProjection, get, getTransform, Projection, transform } from 'ol/proj'
import proj4 from 'proj4'
const MapComponent = () => {
const mapElement = useRef<HTMLDivElement | null>(null)
const [currentTool, setCurrentTool] = useState<Type | null>(null)
const map = useRef<Map | null>(null)
const [satMapsProvider, setSatMapsProvider] = useState<SatelliteMapsProvider>('yandex')
const gMapsSatSource = useRef<XYZ>(googleMapsSatelliteSource)
const yMapsSatSource = useRef<XYZ>(yandexMapsSatelliteSource)
const satLayer = useRef<TileLayer>(new TileLayer({
source: gMapsSatSource.current,
}))
const draw = useRef<Draw | null>(null)
const snap = useRef<Snap | null>(null)
const selectFeature = useRef<Select>(new Select({
condition: function (mapBrowserEvent) {
return click(mapBrowserEvent) && shiftKeyOnly(mapBrowserEvent);
},
}))
const overlayLayer = useRef<VectorLayer | null>(null)
const overlayLayerSource = useRef<VectorSource>(new VectorSource())
const drawingLayer = useRef<VectorLayer | null>(null)
const drawingLayerSource = useRef<VectorSource>(new VectorSource())
const regionsLayer = useRef<VectorImageLayer>(new VectorImageLayer({
source: regionsLayerSource,
style: regionsLayerStyle
}))
const selectedRegion = useRef<Feature | null>(null)
const baseLayer = useRef<TileLayer>(new TileLayer({
source: new OSM(),
}))
const imageLayer = useRef<ImageLayer<ImageStatic>>(new ImageLayer())
const addInteractions = () => {
if (currentTool) {
draw.current = new Draw({
source: drawingLayerSource.current,
type: currentTool,
condition: noModifierKeys
})
map?.current?.addInteraction(draw.current)
snap.current = new Snap({ source: drawingLayerSource.current })
map?.current?.addInteraction(snap.current)
}
}
// Function to save features to localStorage
const saveFeatures = () => {
const features = drawingLayer.current?.getSource()?.getFeatures()
if (features && features.length > 0) {
const geoJSON = new GeoJSON()
const featuresJSON = geoJSON.writeFeatures(features)
localStorage.setItem('savedFeatures', featuresJSON)
}
console.log(drawingLayer.current?.getSource()?.getFeatures())
}
// Function to load features from localStorage
const loadFeatures = () => {
const savedFeatures = localStorage.getItem('savedFeatures')
if (savedFeatures) {
const geoJSON = new GeoJSON()
const features = geoJSON.readFeatures(savedFeatures, {
featureProjection: 'EPSG:4326', // Ensure the projection is correct
})
drawingLayerSource.current?.addFeatures(features) // Add features to the vector source
//drawingLayer.current?.getSource()?.changed()
}
}
const handleToolSelect = (tool: Type) => {
if (currentTool == tool) {
setCurrentTool(null)
} else {
setCurrentTool(tool)
}
}
const zoomToFeature = (feature: Feature) => {
const geometry = feature.getGeometry()
const extent = geometry?.getExtent()
if (map.current && extent) {
map.current.getView().fit(extent, {
duration: 300,
maxZoom: 19,
})
}
}
const style = new Style({
geometry: function (feature) {
const modifyGeometry = feature.get('modifyGeometry');
return modifyGeometry ? modifyGeometry.geometry : feature.getGeometry();
},
fill: new Fill({
color: 'rgba(255, 255, 255, 0.2)',
}),
stroke: new Stroke({
color: '#ffcc33',
width: 2,
}),
image: new CircleStyle({
radius: 7,
fill: new Fill({
color: '#ffcc33',
}),
}),
});
function calculateCenter(geometry: SimpleGeometry) {
let center, coordinates, minRadius;
const type = geometry.getType();
if (type === 'Polygon') {
let x = 0;
let y = 0;
let i = 0;
coordinates = (geometry as Polygon).getCoordinates()[0].slice(1);
coordinates.forEach(function (coordinate) {
x += coordinate[0];
y += coordinate[1];
i++;
});
center = [x / i, y / i];
} else if (type === 'LineString') {
center = (geometry as LineString).getCoordinateAt(0.5);
coordinates = geometry.getCoordinates();
} else {
center = getCenter(geometry.getExtent());
}
let sqDistances;
if (coordinates) {
sqDistances = coordinates.map(function (coordinate: Coordinate) {
const dx = coordinate[0] - center[0];
const dy = coordinate[1] - center[1];
return dx * dx + dy * dy;
});
minRadius = Math.sqrt(Math.max.apply(Math, sqDistances)) / 3;
} else {
minRadius =
Math.max(
getWidth(geometry.getExtent()),
getHeight(geometry.getExtent()),
) / 3;
}
return {
center: center,
coordinates: coordinates,
minRadius: minRadius,
sqDistances: sqDistances,
};
}
function rotateProjection(projection, angle, extent) {
function rotateCoordinate(coordinate, angle, anchor) {
var coord = rotate(
[coordinate[0] - anchor[0], coordinate[1] - anchor[1]],
angle
);
return [coord[0] + anchor[0], coord[1] + anchor[1]];
}
function rotateTransform(coordinate: Coordinate) {
return rotateCoordinate(coordinate, angle, getCenter(extent));
}
function normalTransform(coordinate: Coordinate) {
return rotateCoordinate(coordinate, -angle, getCenter(extent));
}
var normalProjection = get(projection);
var rotatedProjection = new Projection({
code:
normalProjection.getCode() +
":" +
angle.toString() +
":" +
extent.toString(),
units: normalProjection.getUnits(),
extent: extent
});
addProjection(rotatedProjection);
addCoordinateTransforms(
"EPSG:4326",
rotatedProjection,
function (coordinate) {
return rotateTransform(transform(coordinate, "EPSG:4326", projection));
},
function (coordinate) {
return transform(normalTransform(coordinate), projection, "EPSG:4326");
}
);
addCoordinateTransforms(
"EPSG:3857",
rotatedProjection,
function (coordinate) {
return rotateTransform(transform(coordinate, "EPSG:3857", projection));
},
function (coordinate) {
return transform(normalTransform(coordinate), projection, "EPSG:3857");
}
);
// also set up transforms with any projections defined using proj4
if (typeof proj4 !== "undefined") {
var projCodes = Object.keys(proj4.defs);
projCodes.forEach(function (code) {
var proj4Projection = get(code);
if (!getTransform(proj4Projection, rotatedProjection)) {
addCoordinateTransforms(
proj4Projection,
rotatedProjection,
function (coordinate) {
return rotateTransform(
transform(coordinate, proj4Projection, projection)
);
},
function (coordinate) {
return transform(
normalTransform(coordinate),
projection,
proj4Projection
);
}
);
}
});
}
return rotatedProjection;
}
const handleImageDrop = useCallback((event: any) => {
event.preventDefault();
event.stopPropagation();
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => {
const imageUrl = reader.result as string;
const img = new Image();
img.src = imageUrl;
img.onload = () => {
if (map.current) {
const view = map.current.getView();
const center = view.getCenter() || [0, 0];
const width = img.naturalWidth;
const height = img.naturalHeight;
const resolution = view.getResolution() || 0;
const extent = [
center[0] - (width * resolution) / 20,
center[1] - (height * resolution) / 20,
center[0] + (width * resolution) / 20,
center[1] + (height * resolution) / 20,
];
// Create a polygon feature with the same extent as the image
const polygonFeature = new Feature({
geometry: fromExtent(extent),
});
// Add the polygon feature to the drawing layer source
overlayLayerSource.current?.addFeature(polygonFeature);
// Set up the initial image layer with the extent
const imageSource = new ImageStatic({
url: imageUrl,
imageExtent: extent,
});
imageLayer.current.setSource(imageSource);
//map.current.addLayer(imageLayer.current);
// Add interactions for translation and scaling
const translate = new Translate({
layers: [imageLayer.current],
features: new Collection([polygonFeature]),
});
const defaultStyle = new Modify({ source: overlayLayerSource.current })
.getOverlay()
.getStyleFunction();
const modify = new Modify({
insertVertexCondition: never,
source: overlayLayerSource.current,
condition: function (event) {
return primaryAction(event) && !platformModifierKeyOnly(event);
},
deleteCondition: never,
features: new Collection([polygonFeature]),
style: function (feature) {
feature.get('features').forEach(function (modifyFeature: Feature) {
const modifyGeometry = modifyFeature.get('modifyGeometry')
if (modifyGeometry) {
const point = (feature.getGeometry() as Point).getCoordinates()
let modifyPoint = modifyGeometry.point
if (!modifyPoint) {
// save the initial geometry and vertex position
modifyPoint = point;
modifyGeometry.point = modifyPoint;
modifyGeometry.geometry0 = modifyGeometry.geometry;
// get anchor and minimum radius of vertices to be used
const result = calculateCenter(modifyGeometry.geometry0);
modifyGeometry.center = result.center;
modifyGeometry.minRadius = result.minRadius;
}
const center = modifyGeometry.center;
const minRadius = modifyGeometry.minRadius;
let dx, dy;
dx = modifyPoint[0] - center[0];
dy = modifyPoint[1] - center[1];
const initialRadius = Math.sqrt(dx * dx + dy * dy);
if (initialRadius > minRadius) {
const initialAngle = Math.atan2(dy, dx);
dx = point[0] - center[0];
dy = point[1] - center[1];
const currentRadius = Math.sqrt(dx * dx + dy * dy);
if (currentRadius > 0) {
const currentAngle = Math.atan2(dy, dx);
const geometry = modifyGeometry.geometry0.clone();
geometry.scale(currentRadius / initialRadius, undefined, center);
geometry.rotate(currentAngle - initialAngle, center);
modifyGeometry.geometry = geometry;
}
}
}
})
const res = map?.current?.getView()?.getResolution()
if (typeof res === 'number' && feature && defaultStyle) {
return defaultStyle(feature, res)
}
}
});
const calculateCentroid = (bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate) => {
const x = (bottomLeft[0] + topLeft[0] + topRight[0] + bottomRight[0]) / 4;
const y = (bottomLeft[1] + topLeft[1] + topRight[1] + bottomRight[1]) / 4;
return [x, y];
}
const calculateRotationAngle = (bottomLeft: Coordinate, bottomRight: Coordinate) => {
// Calculate the difference in x and y coordinates between bottom right and bottom left
const deltaX = bottomRight[0] - bottomLeft[0];
const deltaY = bottomRight[1] - bottomLeft[1];
// Calculate the angle using atan2
const angle = Math.atan2(deltaY, deltaX);
return angle;
}
const calculateExtent = (bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate) => {
const width = distance(bottomLeft, bottomRight);
const height = distance(bottomLeft, topLeft);
// Calculate the centroid of the polygon
const [centerX, centerY] = calculateCentroid(bottomLeft, topLeft, topRight, bottomRight);
// Define the extent based on the center and dimensions
const extent = [
centerX - width / 2, // minX
centerY - height / 2, // minY
centerX + width / 2, // maxX
centerY + height / 2 // maxY
];
return extent;
}
// Function to update the image layer with a new source when extent changes
const updateImageSource = () => {
const newExtent = polygonFeature.getGeometry()?.getExtent();
const bottomLeft = polygonFeature.getGeometry()?.getCoordinates()[0][0]
const topLeft = polygonFeature.getGeometry()?.getCoordinates()[0][1]
const topRight = polygonFeature.getGeometry()?.getCoordinates()[0][2]
const bottomRight = polygonFeature.getGeometry()?.getCoordinates()[0][3]
if (newExtent && bottomLeft && bottomRight && topRight && topLeft) {
const originalExtent = calculateExtent(bottomLeft, topLeft, topRight, bottomRight)
const newImageSource = new ImageStatic({
url: imageUrl,
imageExtent: originalExtent,
projection: rotateProjection('EPSG:3857', -calculateRotationAngle(bottomLeft, bottomRight), originalExtent)
});
imageLayer.current.setSource(newImageSource);
}
};
translate.on('translateend', updateImageSource);
//modify.on('modifyend', updateImageSource);
modify.on('modifystart', function (event) {
event.features.forEach(function (feature) {
feature.set(
'modifyGeometry',
{ geometry: feature.getGeometry()?.clone() },
true,
);
});
});
modify.on('modifyend', function (event) {
event.features.forEach(function (feature) {
const modifyGeometry = feature.get('modifyGeometry');
if (modifyGeometry) {
feature.setGeometry(modifyGeometry.geometry);
feature.unset('modifyGeometry', true);
}
})
updateImageSource()
})
map.current.addInteraction(translate);
map.current.addInteraction(modify);
}
};
};
reader.readAsDataURL(file);
}
}
}, [])
function regionsInit() {
map.current?.on('click', function (e) {
if (selectedRegion.current !== null) {
selectedRegion.current = null
}
if (map.current) {
map.current.forEachFeatureAtPixel(e.pixel, function (feature, layer) {
if (layer === regionsLayer.current) {
selectedRegion.current = feature as Feature
// Zoom to the selected feature
zoomToFeature(selectedRegion.current)
return true
} else return false
});
}
})
// Show current selected region
map.current?.on('pointermove', function (e) {
if (selectedRegion.current !== null) {
selectedRegion.current.setStyle(undefined)
selectedRegion.current = null
}
if (map.current) {
map.current.forEachFeatureAtPixel(e.pixel, function (feature, layer) {
if (layer === regionsLayer.current) {
selectedRegion.current = feature as Feature
selectedRegion.current.setStyle(selectStyle)
if (feature.get('district')) {
setStatusText(feature.get('district'))
}
return true
} else return false
})
}
})
// Hide regions layer when fully visible
map.current?.on('moveend', function () {
const viewExtent = map.current?.getView().calculateExtent(map.current.getSize())
const features = regionsLayer.current.getSource()?.getFeatures()
let isViewCovered = false
features?.forEach((feature: Feature) => {
const featureExtent = feature?.getGeometry()?.getExtent()
if (viewExtent && featureExtent) {
if (containsExtent(featureExtent, viewExtent)) {
isViewCovered = true
}
}
})
regionsLayer.current.setVisible(!isViewCovered)
})
}
useEffect(() => {
drawingLayer.current = new VectorLayer({
source: drawingLayerSource.current,
style: drawingLayerStyle
})
overlayLayer.current = new VectorLayer({
source: overlayLayerSource.current,
style: function (feature) {
const styles = [style]
const modifyGeometry = feature.get('modifyGeometry')
const geometry = modifyGeometry ? modifyGeometry.geometry : feature.getGeometry()
const result = calculateCenter(geometry)
const center = result.center
if (center) {
styles.push(
new Style({
geometry: new Point(center),
image: new CircleStyle({
radius: 4,
fill: new Fill({
color: '#ff3333'
})
})
})
)
const coordinates = result.coordinates
if (coordinates) {
const minRadius = result.minRadius
const sqDistances = result.sqDistances
const rsq = minRadius * minRadius
if (Array.isArray(sqDistances)) {
const points = coordinates.filter(function (coordinate, index) {
return sqDistances[index] > rsq
})
styles.push(
new Style({
geometry: new MultiPoint(points),
image: new CircleStyle({
radius: 4,
fill: new Fill({
color: '#33cc33'
})
})
})
)
}
}
}
return styles
},
})
map.current = new Map({
layers: [baseLayer.current, satLayer.current, regionsLayer.current, drawingLayer.current, imageLayer.current, overlayLayer.current],
target: mapElement.current as HTMLDivElement,
view: new View({
center: mapCenter,
zoom: 2,
maxZoom: 21,
//extent: mapExtent,
}),
})
const modify = new Modify({ source: drawingLayerSource.current })
map.current.addInteraction(modify)
map.current.addInteraction(selectFeature.current)
selectFeature.current.on('select', (e) => {
const selectedFeatures = e.selected
if (selectedFeatures.length > 0) {
selectedFeatures.forEach((feature) => {
drawingLayerSource.current?.removeFeature(feature)
})
}
})
loadFeatures()
regionsInit()
if (mapElement.current) {
mapElement.current.addEventListener('dragover', (e) => {
e.preventDefault()
})
mapElement.current.addEventListener('drop', handleImageDrop)
}
return () => {
map?.current?.setTarget(undefined)
if (mapElement.current) {
mapElement.current.removeEventListener('drop', handleImageDrop)
}
}
}, [])
useEffect(() => {
if (currentTool) {
if (draw.current) map?.current?.removeInteraction(draw.current)
if (snap.current) map?.current?.removeInteraction(snap.current)
addInteractions()
} else {
if (draw.current) map?.current?.removeInteraction(draw.current)
if (snap.current) map?.current?.removeInteraction(snap.current)
}
}, [currentTool])
const [satelliteOpacity, setSatelliteOpacity] = useState<number>(0)
const [statusText, setStatusText] = useState('')
// Visibility setting
useEffect(() => {
satLayer.current?.setOpacity(satelliteOpacity)
if (satelliteOpacity == 0) {
baseLayer.current?.setVisible(true)
satLayer.current?.setVisible(false)
} if (satelliteOpacity == 1) {
baseLayer.current?.setVisible(false)
satLayer.current?.setVisible(true)
} else if (satelliteOpacity > 0 && satelliteOpacity < 1) {
baseLayer.current?.setVisible(true)
satLayer.current?.setVisible(true)
}
}, [satelliteOpacity])
// Satellite tiles setting
useEffect(() => {
satLayer.current?.setSource(satMapsProvider == 'google' ? gMapsSatSource.current : satMapsProvider == 'yandex' ? yMapsSatSource.current : gMapsSatSource.current)
satLayer.current?.getSource()?.refresh()
}, [satMapsProvider])
return (
<Stack flex={1} flexDirection='column'>
<Stack my={1} spacing={1} direction='row' divider={<Divider orientation='vertical' flexItem />}>
<IconButton title='Добавить подложку'>
<Add />
</IconButton>
</Stack>
<Stack my={1} spacing={1} direction='row' divider={<Divider orientation='vertical' flexItem />}>
<Stack flex={1} alignItems='center' justifyContent='center'>
<Slider aria-label="Opacity" min={0} max={1} step={0.001} defaultValue={satelliteOpacity} value={satelliteOpacity} onChange={(_, value) => setSatelliteOpacity(Array.isArray(value) ? value[0] : value)} />
</Stack>
<MUISelect
variant='standard'
labelId="demo-simple-select-label"
id="demo-simple-select"
value={satMapsProvider}
label="Satellite Provider"
onChange={(e) => setSatMapsProvider(e.target.value as SatelliteMapsProvider)}
>
<MenuItem value={'google'}>Google</MenuItem>
<MenuItem value={'yandex'}>Яндекс</MenuItem>
</MUISelect>
<IconButton onClick={() => {
fetch(`${import.meta.env.VITE_API_EMS_URL}/hello`, { method: 'GET' }).then(res => console.log(res))
}}>
<Api />
</IconButton>
<IconButton onClick={() => {
saveFeatures()
}}>
<Warning />
</IconButton>
<IconButton
onClick={() => {
draw.current?.removeLastPoint()
}}>
<Undo />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Point' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('Point')}>
<Adjust />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'LineString' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('LineString')}>
<Timeline />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Polygon' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('Polygon')}>
<RectangleOutlined />
</IconButton>
<IconButton
sx={{ backgroundColor: currentTool === 'Circle' ? 'Highlight' : 'transparent' }}
onClick={() => handleToolSelect('Circle')}>
<CircleOutlined />
</IconButton>
<IconButton
onClick={() => map?.current?.addInteraction(new Translate())}
>
<OpenWith />
</IconButton>
<IconButton>
<Straighten />
</IconButton>
</Stack>
<Box>
<div id="map-container" ref={mapElement} style={{ width: '100%', height: '600px', maxHeight: '100%', position: 'relative', flexGrow: 1 }}></div>
</Box>
<Stack>
{statusText}
</Stack>
</Stack>
);
};
export default MapComponent