import express, { Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; import axios from 'axios'; import multer from 'multer' import sharp from 'sharp'; 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', 'temp'); app.use(cors()) app.use(bodyParser.json()) app.use(bodyParser.urlencoded({ extended: true })); const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, path.join(__dirname, '..', 'public', 'temp')) }, filename: function (req, file, cb) { 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] const xNormalized = normalize(x, minX, maxX) const yNormalized = normalize(y, minY, maxY) const tileX = getTileIndex(xNormalized, tilesPerSide) const tileY = getTileIndex(1 - yNormalized, tilesPerSide) return { tileX, tileY } } function calculateRotationAngle(bottomLeft: Coordinate, bottomRight: Coordinate) { const deltaX = bottomRight.x - bottomLeft.x const deltaY = bottomRight.y - bottomLeft.y const angle = -Math.atan2(deltaY, deltaX) return angle } function roundUpToNearest(number: number, mod: number) { return Math.floor(number / mod) * mod } async function generateTilesForZoomLevel(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 { let perPixel = width / pixelWidth // constraint to original image width const imageMetadata = await sharp(file.path).metadata().then(res => { if (res.width) { perPixel = pixelWidth <= res.width ? perPixel : width / res.width } }) 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 const boundsHeightPixel = Math.abs(polygonExtent[1] - polygonExtent[3]) / perPixel if (!fs.existsSync(path.join(tileFolder, 'custom', zoomLevel.toString()))) { fs.mkdirSync(path.join(tileFolder, 'custom', zoomLevel.toString()), { recursive: true }); } const initialZoomImage = await sharp(path.join(uploadDir, file.filename)) .rotate(Math.ceil(angleDegrees), { background: '#00000000' }) .resize({ width: Math.ceil(boundsWidthPixel), height: Math.ceil(boundsHeightPixel), background: '#00000000' }) .extend({ top: Math.ceil(paddingTopPixel), left: Math.ceil(paddingLeftPixel), bottom: Math.ceil(paddingBottomPixel), right: Math.ceil(paddingRightPixel), background: '#00000000' }) .toFormat('png') .toBuffer({ resolveWithObject: true }) if (initialZoomImage) { await sharp(initialZoomImage.data.buffer) .resize({ width: roundUpToNearest(Math.ceil(boundsWidthPixel) + Math.ceil(paddingLeftPixel) + Math.ceil(paddingRightPixel), Math.abs(minX - (maxX + 1))), height: roundUpToNearest(Math.ceil(boundsHeightPixel) + Math.ceil(paddingTopPixel) + Math.ceil(paddingBottomPixel), Math.abs(minY - (maxY + 1))), }) .toFile(path.join(tileFolder, 'custom', zoomLevel.toString(), zoomLevel.toString() + '.png')) .then(async (res) => { 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: res.width / Math.abs(minX - (maxX + 1)), height: res.height / Math.abs(minY - (maxY + 1)), left: left, top: top }) .toFile(path.join(tileFolder, 'custom', zoomLevel.toString(), x.toString(), y.toString() + '.png')) .then(() => { top = top + res.height / Math.abs(minY - (maxY + 1)) }) } catch (error) { console.log(error) } } left = left + res.width / Math.abs(minX - (maxX + 1)) } }) } } 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 <= 21; z++) { await generateTilesForZoomLevel(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}` : `https://core-sat.maps.yandex.net/tiles?l=sat&x=${x}&y=${y}&z=${z}&scale=1&lang=ru_RU`; const response = await axios.get(url, { responseType: 'arraybuffer' }); return response.data; } app.get('/tile/:provider/:z/:x/:y', async (req: Request, res: Response) => { const { provider, z, x, y } = req.params; if (!['google', 'yandex', 'custom'].includes(provider)) { return res.status(400).send('Invalid provider'); } 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); } else { if (provider !== 'custom') { try { const tileData = await fetchTileFromAPI(provider, z, x, y); fs.mkdirSync(path.dirname(tilePath), { recursive: true }); 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'); } } else { res.status(404).send('Tile is not generated or not provided') } } }); app.listen(PORT, () => console.log(`Server running on port ${PORT}`));