From 8c8c619143548867ab310fb477d014d9d60891c3 Mon Sep 17 00:00:00 2001 From: cracklesparkle Date: Mon, 9 Sep 2024 17:49:32 +0900 Subject: [PATCH] Tile generation --- client/src/components/map/MapComponent.tsx | 119 ++++++++- client/src/interfaces/map.ts | 1 + ems/src/constants/index.ts | 12 + ems/src/index.ts | 290 +++++++++++++++++---- ems/src/interfaces/map.ts | 9 +- 5 files changed, 362 insertions(+), 69 deletions(-) create mode 100644 ems/src/constants/index.ts diff --git a/client/src/components/map/MapComponent.tsx b/client/src/components/map/MapComponent.tsx index e13f007..eda6e17 100644 --- a/client/src/components/map/MapComponent.tsx +++ b/client/src/components/map/MapComponent.tsx @@ -7,7 +7,7 @@ import { Draw, Modify, Select, Snap, Translate } from 'ol/interaction' import { ImageStatic, OSM, TileDebug, 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, Typography } from '@mui/material' -import { Add, Adjust, Api, CircleOutlined, OpenWith, RectangleOutlined, Straighten, Timeline, Undo, Warning } from '@mui/icons-material' +import { Add, Adjust, Api, CircleOutlined, OpenWith, RectangleOutlined, Straighten, Timeline, Undo, Upload, 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' @@ -26,6 +26,7 @@ import { Stroke, Fill, Circle as CircleStyle, Style } from 'ol/style' import { calculateExtent, calculateRotationAngle, rotateProjection } from './mapUtils' import MapBrowserEvent from 'ol/MapBrowserEvent' import { get } from 'ol/proj' +import axios from 'axios' const MapComponent = () => { const [currentCoordinate, setCurrentCoordinate] = useState(null) @@ -33,15 +34,29 @@ const MapComponent = () => { const [currentX, setCurrentX] = useState(undefined) const [currentY, setCurrentY] = useState(undefined) + const [testExtent, setTestExtent] = useState(null) + + const [file, setFile] = useState(null) + const [polygonExtent, setPolygonExtent] = useState(undefined) + const [bottomLeft, setBottomLeft] = useState(undefined) + const [topLeft, setTopLeft] = useState(undefined) + const [topRight, setTopRight] = useState(undefined) + const [bottomRight, setBottomRight] = useState(undefined) + const mapElement = useRef(null) const [currentTool, setCurrentTool] = useState(null) const map = useRef(null) - const [satMapsProvider, setSatMapsProvider] = useState('yandex') + const [satMapsProvider, setSatMapsProvider] = useState('custom') const gMapsSatSource = useRef(googleMapsSatelliteSource) + const customMapSource = useRef(new XYZ({ + url: `${import.meta.env.VITE_API_EMS_URL}/tile/custom/{z}/{x}/{y}`, + attributions: 'Custom map data' + })) + const yMapsSatSource = useRef(yandexMapsSatelliteSource) const satLayer = useRef(new TileLayer({ @@ -204,6 +219,7 @@ const MapComponent = () => { const files = event.dataTransfer.files; if (files.length > 0) { const file = files[0]; + setFile(file) if (file.type.startsWith('image/')) { const reader = new FileReader(); @@ -316,6 +332,12 @@ const MapComponent = () => { const topRight = polygonFeature.getGeometry()?.getCoordinates()[0][2] const bottomRight = polygonFeature.getGeometry()?.getCoordinates()[0][3] + setPolygonExtent(newExtent) + setBottomLeft(bottomLeft) + setTopLeft(topLeft) + setTopRight(topRight) + setBottomRight(bottomRight) + if (newExtent && bottomLeft && bottomRight && topRight && topLeft) { const originalExtent = calculateExtent(bottomLeft, topLeft, topRight, bottomRight) @@ -332,17 +354,59 @@ const MapComponent = () => { const mapWidth = Math.abs(worldExtent[0] - worldExtent[2]) const mapHeight = Math.abs(worldExtent[1] - worldExtent[3]) - const tileWidth = mapWidth / (zoomLevel * 4) - const tileHeight = mapHeight / (zoomLevel * 4) - const newMinX = worldExtent[0] + (tileWidth * minX) - const newMaxX = worldExtent[0] + (tileWidth * (maxX + 1)) - const newMinY = worldExtent[1] + (tileHeight * (maxY + 1)) - const newMaxY = worldExtent[1] + (tileHeight * minY) + const tilesH = Math.sqrt(Math.pow(4, zoomLevel)) + const tileWidth = mapWidth / (Math.sqrt(Math.pow(4, zoomLevel))) + const tileHeight = mapHeight / (Math.sqrt(Math.pow(4, zoomLevel))) + + let minPosX = minX - (tilesH / 2) + let maxPosX = maxX - (tilesH / 2) + 1 + let minPosY = -(minY - (tilesH / 2)) + let maxPosY = -(maxY - (tilesH / 2) + 1) + console.log(`tileWidth: ${tileWidth} minPosX: ${minPosX} maxPosX: ${maxPosX} minPosY: ${minPosY} maxPosY: ${maxPosY}`) + + const newMinX = tileWidth * minPosX + const newMaxX = tileWidth * maxPosX + const newMinY = tileHeight * maxPosY + const newMaxY = tileHeight * minPosY console.log('Tile slippy bounds: ', minX, maxX, minY, maxY) console.log('Tile bounds: ', newMinX, newMaxX, newMinY, newMaxY) + const angleDegrees = calculateRotationAngle(bottomLeft, bottomRight) * 180 / Math.PI + + const paddingLeft = Math.abs(newExtent[0] - newMinX) + const paddingRight = Math.abs(newExtent[2] - newMaxX) + const paddingTop = Math.abs(newExtent[3] - newMaxY) + const paddingBottom = Math.abs(newExtent[1] - newMinY) + + const pixelWidth = Math.abs(minX - (maxX + 1)) * 256 + const pixelHeight = Math.abs(minY - (maxY + 1)) * 256 + + const width = Math.abs(newMinX - newMaxX) + const perPixel = width / pixelWidth + + const paddingLeftPixel = paddingLeft / perPixel + const paddingRightPixel = paddingRight / perPixel + const paddingTopPixel = paddingTop / perPixel + const paddingBottomPixel = paddingBottom / perPixel + + console.log('Rotation angle degrees: ', angleDegrees) + + console.log('Padding top pixel: ', paddingTopPixel) + console.log('Padding left pixel: ', paddingLeftPixel) + console.log('Padding right pixel: ', paddingRightPixel) + console.log('Padding bottom pixel: ', paddingBottomPixel) + + console.log('Per pixel: ', width / pixelWidth) + + const boundsWidthPixel = Math.abs(newExtent[0] - newExtent[2]) / perPixel + const boundsHeightPixel = Math.abs(newExtent[1] - newExtent[3]) / perPixel + console.log('Bounds width pixel', boundsWidthPixel) + console.log('Bounds height pixel', boundsHeightPixel) + + // Result will be sharp rotate(angleDegrees), resize(boundsWidthPixel), extend() + const newImageSource = new ImageStatic({ url: imageUrl, imageExtent: originalExtent, @@ -623,10 +687,31 @@ const MapComponent = () => { // Satellite tiles setting useEffect(() => { - satLayer.current?.setSource(satMapsProvider == 'google' ? gMapsSatSource.current : satMapsProvider == 'yandex' ? yMapsSatSource.current : gMapsSatSource.current) + satLayer.current?.setSource(satMapsProvider == 'google' ? gMapsSatSource.current : satMapsProvider == 'yandex' ? yMapsSatSource.current : satMapsProvider == 'custom' ? customMapSource.current : gMapsSatSource.current) satLayer.current?.getSource()?.refresh() }, [satMapsProvider]) + const submitOverlay = async () => { + if (file && polygonExtent && bottomLeft && topLeft && topRight && bottomRight) { + const formData = new FormData() + formData.append('file', file) + formData.append('extentMinX', polygonExtent[0].toString()) + formData.append('extentMinY', polygonExtent[1].toString()) + formData.append('extentMaxX', polygonExtent[2].toString()) + formData.append('extentMaxY', polygonExtent[3].toString()) + formData.append('blX', bottomLeft[0].toString()) + formData.append('blY', bottomLeft[1].toString()) + formData.append('tlX', topLeft[0].toString()) + formData.append('tlY', topLeft[1].toString()) + formData.append('trX', topRight[0].toString()) + formData.append('trY', topRight[1].toString()) + formData.append('brX', bottomRight[0].toString()) + formData.append('brY', bottomRight[1].toString()) + + await fetch(`${import.meta.env.VITE_API_EMS_URL}/upload`, { method: 'POST', body: formData }) + } + } + return ( }> @@ -634,15 +719,24 @@ const MapComponent = () => { - - {currentCoordinate?.[0]}-{currentCoordinate?.[1]} - + + + x: {currentCoordinate?.[0]} + + + y: {currentCoordinate?.[1]} + + Z={currentZ} X={currentX} Y={currentY} + + submitOverlay()}> + + }> @@ -660,6 +754,7 @@ const MapComponent = () => { > Google Яндекс + Custom diff --git a/client/src/interfaces/map.ts b/client/src/interfaces/map.ts index d89246a..e8c7e1a 100644 --- a/client/src/interfaces/map.ts +++ b/client/src/interfaces/map.ts @@ -1,5 +1,6 @@ export interface SatelliteMapsProviders { google: 'google'; yandex: 'yandex'; + custom: 'custom'; } export type SatelliteMapsProvider = SatelliteMapsProviders[keyof SatelliteMapsProviders] \ No newline at end of file diff --git a/ems/src/constants/index.ts b/ems/src/constants/index.ts new file mode 100644 index 0000000..a4e7991 --- /dev/null +++ b/ems/src/constants/index.ts @@ -0,0 +1,12 @@ +import { Extent } from "../interfaces/map" + +const epsg3857extent = [ + -20037508.342789244, + -20037508.342789244, + 20037508.342789244, + 20037508.342789244 +] as Extent + +export { + epsg3857extent +} \ No newline at end of file diff --git a/ems/src/index.ts b/ems/src/index.ts index 33a17d8..faf28a4 100644 --- a/ems/src/index.ts +++ b/ems/src/index.ts @@ -9,12 +9,14 @@ import pump from 'pump' import md5 from 'md5' import bodyParser from 'body-parser'; import cors from 'cors' +import { Coordinate, Extent } from './interfaces/map'; +import { epsg3857extent } from './constants'; const app = express(); const PORT = process.env.EMS_PORT || 5000; const tileFolder = path.join(__dirname, '..', 'public', 'tile_data'); -const uploadDir = path.join(__dirname, '..', 'public', 'uploads'); +const uploadDir = path.join(__dirname, '..', 'public', 'temp'); interface UploadProgress { receivedChunks: Set; @@ -23,53 +25,55 @@ interface UploadProgress { const uploadProgress: Record = {}; -app.use(bodyParser.raw({ - type: 'application/octet-stream', - limit: '100mb' -})) - app.use(cors()) +app.use(bodyParser.json()) + +app.use(bodyParser.urlencoded({ extended: true })); + // Upload chunk handler -app.post('/upload', (req: Request, res: Response) => { - const chunkNumber = parseInt(req.headers['x-chunk-number'] as string, 10); - const totalChunks = parseInt(req.headers['x-total-chunks'] as string, 10); - const fileId = req.headers['x-file-id'] as string; +// app.post('/upload', bodyParser.raw({ +// type: 'application/octet-stream', +// limit: '100mb' +// }), (req: Request, res: Response) => { +// const chunkNumber = parseInt(req.headers['x-chunk-number'] as string, 10); +// const totalChunks = parseInt(req.headers['x-total-chunks'] as string, 10); +// const fileId = req.headers['x-file-id'] as string; - if (isNaN(chunkNumber) || isNaN(totalChunks) || !fileId) { - return res.status(400).send('Invalid headers'); - } +// if (isNaN(chunkNumber) || isNaN(totalChunks) || !fileId) { +// return res.status(400).send('Invalid headers'); +// } - const chunkDir = path.join(uploadDir, fileId); - if (!fs.existsSync(chunkDir)) { - fs.mkdirSync(chunkDir, { recursive: true }); - } +// const chunkDir = path.join(uploadDir, fileId); +// if (!fs.existsSync(chunkDir)) { +// fs.mkdirSync(chunkDir, { recursive: true }); +// } - // Save the chunk - const chunkPath = path.join(chunkDir, `chunk-${chunkNumber}`); - fs.writeFileSync(chunkPath, req.body); +// // Save the chunk +// const chunkPath = path.join(chunkDir, `chunk-${chunkNumber}`); +// fs.writeFileSync(chunkPath, req.body); - // Initialize or update upload progress - if (!uploadProgress[fileId]) { - uploadProgress[fileId] = { receivedChunks: new Set(), totalChunks }; - } - uploadProgress[fileId].receivedChunks.add(chunkNumber); - - // Check if all chunks have been received - if (uploadProgress[fileId].receivedChunks.size === totalChunks) { - assembleChunks(fileId, chunkDir) - .then(() => { - delete uploadProgress[fileId]; // Clean up progress tracking - res.status(200).send('File assembled successfully'); - }) - .catch((error) => { - console.error('Error assembling file:', error); - res.status(500).send('Failed to assemble file'); - }); - } else { - res.status(200).send('Chunk received'); - } -}); +// // Initialize or update upload progress +// if (!uploadProgress[fileId]) { +// uploadProgress[fileId] = { receivedChunks: new Set(), totalChunks }; +// } +// uploadProgress[fileId].receivedChunks.add(chunkNumber); + +// // Check if all chunks have been received +// if (uploadProgress[fileId].receivedChunks.size === totalChunks) { +// assembleChunks(fileId, chunkDir) +// .then(() => { +// delete uploadProgress[fileId]; // Clean up progress tracking +// res.status(200).send('File assembled successfully'); +// }) +// .catch((error) => { +// console.error('Error assembling file:', error); +// res.status(500).send('Failed to assemble file'); +// }); +// } else { +// res.status(200).send('Chunk received'); +// } +// }); // Assemble chunks into final file async function assembleChunks(fileId: string, chunkDir: string): Promise { @@ -95,15 +99,185 @@ async function assembleChunks(fileId: string, chunkDir: string): Promise { const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, path.join(__dirname, '..', 'public', 'uploads')) + cb(null, path.join(__dirname, '..', 'public', 'temp')) }, filename: function (req, file, cb) { - cb(null, file.originalname) + cb(null, Date.now() + path.extname(file.originalname)) } }) const upload = multer({ storage: storage }) +function getTilesPerSide(zoom: number) { + return Math.pow(2, zoom) +} + +function normalize(value: number, min: number, max: number) { + return (value - min) / (max - min) +} + +function getTileIndex(normalized: number, tilesPerSide: number) { + return Math.floor(normalized * tilesPerSide) +} + +function getGridCellPosition(x: number, y: number, extent: Extent, zoom: number) { + const tilesPerSide = getTilesPerSide(zoom); + const minX = extent[0] + const minY = extent[1] + const maxX = extent[2] + const maxY = extent[3] + + // Normalize the coordinates + const xNormalized = normalize(x, minX, maxX); + const yNormalized = normalize(y, minY, maxY); + + // Get tile indices + const tileX = getTileIndex(xNormalized, tilesPerSide); + const tileY = getTileIndex(1 - yNormalized, tilesPerSide); + + return { tileX, tileY }; +} + +function calculateRotationAngle(bottomLeft: Coordinate, bottomRight: Coordinate) { + // Calculate the difference in x and y coordinates between bottom right and bottom left + const deltaX = bottomRight.x - bottomLeft.x; + const deltaY = bottomRight.y - bottomLeft.y; + + // Calculate the angle using atan2 + const angle = -Math.atan2(deltaY, deltaX); + + return angle; +} + +async function initialImage(file: Express.Multer.File, polygonExtent: Extent, bottomLeft: Coordinate, topLeft: Coordinate, topRight: Coordinate, bottomRight: Coordinate, zoomLevel: number) { + const angleDegrees = calculateRotationAngle(bottomLeft, bottomRight) * 180 / Math.PI + + const { tileX: blX, tileY: blY } = getGridCellPosition(bottomLeft.x, bottomLeft.y, epsg3857extent, zoomLevel) + const { tileX: tlX, tileY: tlY } = getGridCellPosition(topLeft.x, topLeft.y, epsg3857extent, zoomLevel) + const { tileX: trX, tileY: trY } = getGridCellPosition(topRight.x, topRight.y, epsg3857extent, zoomLevel) + const { tileX: brX, tileY: brY } = getGridCellPosition(bottomRight.x, topRight.y, epsg3857extent, zoomLevel) + + const minX = Math.min(blX, tlX, trX, brX) + const maxX = Math.max(blX, tlX, trX, brX) + const minY = Math.min(blY, tlY, trY, brY) + const maxY = Math.max(blY, tlY, trY, brY) + + const mapWidth = Math.abs(epsg3857extent[0] - epsg3857extent[2]) + const mapHeight = Math.abs(epsg3857extent[1] - epsg3857extent[3]) + + const tilesH = Math.sqrt(Math.pow(4, zoomLevel)) + const tileWidth = mapWidth / (Math.sqrt(Math.pow(4, zoomLevel))) + const tileHeight = mapHeight / (Math.sqrt(Math.pow(4, zoomLevel))) + + + let minPosX = minX - (tilesH / 2) + let maxPosX = maxX - (tilesH / 2) + 1 + let minPosY = -(minY - (tilesH / 2)) + let maxPosY = -(maxY - (tilesH / 2) + 1) + + const newMinX = tileWidth * minPosX + const newMaxX = tileWidth * maxPosX + const newMinY = tileHeight * maxPosY + const newMaxY = tileHeight * minPosY + + + const paddingLeft = Math.abs(polygonExtent[0] - newMinX) + const paddingRight = Math.abs(polygonExtent[2] - newMaxX) + const paddingTop = Math.abs(polygonExtent[3] - newMaxY) + const paddingBottom = Math.abs(polygonExtent[1] - newMinY) + + const pixelWidth = Math.abs(minX - (maxX + 1)) * 256 + const pixelHeight = Math.abs(minY - (maxY + 1)) * 256 + + const width = Math.abs(newMinX - newMaxX) + + try { + const imageMetadata = await sharp(file.path).metadata(); + const originalImageWidth = imageMetadata.width + const originalImageHeight = imageMetadata.height + + const perPixel = width / pixelWidth + console.log(perPixel) + + const paddingLeftPixel = paddingLeft / perPixel + const paddingRightPixel = paddingRight / perPixel + const paddingTopPixel = paddingTop / perPixel + const paddingBottomPixel = paddingBottom / perPixel + + const boundsWidthPixel = Math.abs(polygonExtent[0] - polygonExtent[2]) / perPixel + + if (!fs.existsSync(path.join(tileFolder, 'custom', zoomLevel.toString()))) { + fs.mkdirSync(path.join(tileFolder, 'custom', zoomLevel.toString()), { recursive: true }); + } + + await sharp(path.join(uploadDir, file.filename)) + .rotate(Math.ceil(angleDegrees), { + background: '#00000000' + }) + .resize({ + width: Math.ceil(boundsWidthPixel), + background: '#00000000' + }) + .extend({ + top: Math.ceil(paddingTopPixel), + left: Math.ceil(paddingLeftPixel), + bottom: Math.ceil(paddingBottomPixel), + right: Math.ceil(paddingRightPixel), + background: '#00000000' + }) + .toFile(path.join(tileFolder, 'custom', zoomLevel.toString(), zoomLevel.toString() + '.png')) + .then(async () => { + let left = 0 + for (let x = minX; x <= maxX; x++) { + if (!fs.existsSync(path.join(tileFolder, 'custom', zoomLevel.toString(), x.toString()))) { + fs.mkdirSync(path.join(tileFolder, 'custom', zoomLevel.toString(), x.toString()), { recursive: true }); + } + + let top = 0 + for (let y = minY; y <= maxY; y++) { + console.log(`z: ${zoomLevel} x: ${x} y: ${y}`) + + try { + await sharp(path.join(tileFolder, 'custom', zoomLevel.toString(), zoomLevel.toString() + '.png')) + .extract({ + width: 256, + height: 256, + left: left, + top: top + }) + .toFile(path.join(tileFolder, 'custom', zoomLevel.toString(), x.toString(), y.toString() + '.png')) + .then(() => { + top = top + 256 + }) + } catch (error) { + console.log(error) + } + } + left = left + 256 + } + }) + } catch (error) { + console.log(error) + } +} + +app.post('/upload', upload.single('file'), async (req: Request, res: Response) => { + const { extentMinX, extentMinY, extentMaxX, extentMaxY, blX, blY, tlX, tlY, trX, trY, brX, brY } = req.body + + const bottomLeft: Coordinate = { x: blX, y: blY } + const topLeft: Coordinate = { x: tlX, y: tlY } + const topRight: Coordinate = { x: trX, y: trY } + const bottomRight: Coordinate = { x: brX, y: brY } + + if (req.file) { + for (let z = 0; z <= 10; z++) { + await initialImage(req.file, [extentMinX, extentMinY, extentMaxX, extentMaxY], bottomLeft, topLeft, topRight, bottomRight, z) + } + } + + return res.status(200) +}) + const fetchTileFromAPI = async (provider: string, z: string, x: string, y: string): Promise => { const url = provider === 'google' ? `https://khms2.google.com/kh/v=984?x=${x}&y=${y}&z=${z}` @@ -116,28 +290,32 @@ const fetchTileFromAPI = async (provider: string, z: string, x: string, y: strin app.get('/tile/:provider/:z/:x/:y', async (req: Request, res: Response) => { const { provider, z, x, y } = req.params; - if (!['google', 'yandex'].includes(provider)) { + if (!['google', 'yandex', 'custom'].includes(provider)) { return res.status(400).send('Invalid provider'); } - const tilePath = path.join(tileFolder, provider, z, x, `${y}.jpg`); + const tilePath = provider === 'custom' ? path.join(tileFolder, provider, z, x, `${y}.png`) : path.join(tileFolder, provider, z, x, `${y}.jpg`); if (fs.existsSync(tilePath)) { return res.sendFile(tilePath); - } - - try { - const tileData = await fetchTileFromAPI(provider, z, x, y); + } else { + if (provider !== 'custom') { + try { + const tileData = await fetchTileFromAPI(provider, z, x, y); - fs.mkdirSync(path.dirname(tilePath), { recursive: true }); + fs.mkdirSync(path.dirname(tilePath), { recursive: true }); - fs.writeFileSync(tilePath, tileData); + fs.writeFileSync(tilePath, tileData); - res.contentType('image/jpeg'); - res.send(tileData); - } catch (error) { - console.error('Error fetching tile from API:', error); - res.status(500).send('Error fetching tile from API'); + res.contentType('image/jpeg'); + res.send(tileData); + } catch (error) { + console.error('Error fetching tile from API:', error); + res.status(500).send('Error fetching tile from API'); + } + } else { + res.status(404).send('Tile is not generated or not provided') + } } }); diff --git a/ems/src/interfaces/map.ts b/ems/src/interfaces/map.ts index d89246a..4954914 100644 --- a/ems/src/interfaces/map.ts +++ b/ems/src/interfaces/map.ts @@ -2,4 +2,11 @@ export interface SatelliteMapsProviders { google: 'google'; yandex: 'yandex'; } -export type SatelliteMapsProvider = SatelliteMapsProviders[keyof SatelliteMapsProviders] \ No newline at end of file +export type SatelliteMapsProvider = SatelliteMapsProviders[keyof SatelliteMapsProviders] + +export interface Coordinate { + x: number, + y: number +} + +export type Extent = [number, number, number, number] \ No newline at end of file