nestjs rewrite

This commit is contained in:
popovspiridon99
2025-08-01 11:33:40 +09:00
parent 145827ab6d
commit 37bfa912a0
58 changed files with 17130 additions and 0 deletions

View File

@ -0,0 +1,52 @@
import { ApiProperty } from "@nestjs/swagger"
import { IsNumberString } from "class-validator"
export class UploadDTO {
@ApiProperty()
@IsNumberString()
extentMinX: number
@ApiProperty()
@IsNumberString()
extentMinY: number
@ApiProperty()
@IsNumberString()
extentMaxX: number
@ApiProperty()
@IsNumberString()
extentMaxY: number
@ApiProperty()
@IsNumberString()
blX: number
@ApiProperty()
@IsNumberString()
blY: number
@ApiProperty()
@IsNumberString()
tlX: number
@ApiProperty()
@IsNumberString()
tlY: number
@ApiProperty()
@IsNumberString()
trX: number
@ApiProperty()
@IsNumberString()
trY: number
@ApiProperty()
@IsNumberString()
brX: number
@ApiProperty()
@IsNumberString()
brY: number
}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TilesController } from './tiles.controller';
describe('TilesController', () => {
let controller: TilesController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TilesController],
}).compile();
controller = module.get<TilesController>(TilesController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,82 @@
import { Body, Controller, Get, NotFoundException, Param, ParseEnumPipe, ParseIntPipe, Post, StreamableFile, UploadedFile, UseInterceptors } from '@nestjs/common';
import { TilesService } from './tiles.service';
import { join } from 'path';
import { createReadStream, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import axios from 'axios';
import { dirname } from 'node:path';
import { FileInterceptor } from '@nestjs/platform-express';
import { UploadDTO } from './dto/upload';
type TileProvider = 'google' | 'yandex' | 'custom'
const tileFolder = join(__dirname, '..', '..', '..', 'storage', 'tile_data')
const uploadDir = join(__dirname, '..', '..', '..', 'storage', 'temp')
@Controller('tiles')
export class TilesController {
constructor(private readonly tileService: TilesService) { }
@Post('/upload')
@UseInterceptors(FileInterceptor('file', {
dest: '../../storage/temp'
}))
async uploadFile(
@UploadedFile() file: Express.Multer.File,
@Body() body: UploadDTO
) {
//await this.tileService.processUpload(file, body)
return { message: 'Uploaded successfully', path: file.path }
}
@Get('/tile/:provider/:z/:x/:y')//✅
async getTile(@Param('provider') provider: TileProvider, @Param('z', new ParseIntPipe()) z: string, @Param('x', new ParseIntPipe()) x: string, @Param('y', new ParseIntPipe()) y: string) {
const tilePath = provider === 'custom' ? join(tileFolder, provider, z.toString(), x.toString(), `${y}.png`) : join(tileFolder, provider, z.toString(), x.toString(), `${y}.jpg`)
if (existsSync(tilePath)) {
const file = createReadStream(tilePath)
return new StreamableFile(file, {
type: 'image/jpeg',
})
} else {
if (provider !== 'custom') {
try {
const tileData = await this.tileService.fetchTileFromAPI(provider, z, x, y)
mkdirSync(dirname(tilePath), { recursive: true })
writeFileSync(tilePath, tileData)
return new StreamableFile(tileData, {
type: 'image/jpeg',
})
} catch (error) {
return error
}
} else {
throw new NotFoundException(`Tile is not generated or not provided`)
}
}
}
@Get('/static/:city_id')
async getStatic(@Param('city_id', new ParseIntPipe()) city_id: number) {
const staticFolder = join(__dirname, '..', '..', '..', 'storage', 'static')
const tilePath1 = join(staticFolder, `${city_id}.jpg`)
const tilePath2 = join(staticFolder, `${city_id}.png`)
if (existsSync(tilePath1)) {
const file = createReadStream(tilePath1)
return new StreamableFile(file, {
type: 'image/jpeg',
})
} else if (existsSync(tilePath2)) {
const file = createReadStream(tilePath2)
return new StreamableFile(file, {
type: 'image/png',
})
} else {
throw new NotFoundException(`Static image for city_id = ${city_id} is not provided`)
}
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TilesController } from './tiles.controller';
import { TilesService } from './tiles.service';
@Module({
controllers: [TilesController],
providers: [TilesService]
})
export class TilesModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TilesService } from './tiles.service';
describe('TilesService', () => {
let service: TilesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TilesService],
}).compile();
service = module.get<TilesService>(TilesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,65 @@
import { Injectable, Logger } from '@nestjs/common';
import { UploadDTO } from './dto/upload';
import { generateTilesForZoomLevel } from './utils/tiles';
import { join } from 'path';
import axios from 'axios';
import { randomUUID } from 'crypto';
export interface Coordinate {
x: number,
y: number
}
const tileFolder = join(__dirname, '..', '..', '..', 'storage', 'tile_data')
const uploadDir = join(__dirname, '..', '..', '..', 'storage', 'temp')
@Injectable()
export class TilesService {
async fetchTileFromAPI(provider: string, z: string, x: string, y: string): Promise<Buffer> {
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
}
async processUpload(file: Express.Multer.File, body: UploadDTO) {
const {
extentMinX,
extentMinY,
extentMaxX,
extentMaxY,
blX,
blY,
tlX,
tlY,
trX,
trY,
brX,
brY
} = 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 }
Logger.log(`generating to ${uploadDir} ${tileFolder} ${randomUUID().toString()}`)
if (file) {
for (let z = 0; z <= 21; z++) {
await generateTilesForZoomLevel(
uploadDir,
tileFolder,
file,
[extentMinX, extentMinY, extentMaxX, extentMaxY],
bottomLeft,
topLeft,
topRight,
bottomRight,
z,
)
}
}
}
}

View File

@ -0,0 +1,169 @@
import { Logger } from "@nestjs/common"
import { existsSync, mkdirSync } from "fs"
import { join } from "path"
import * as sharp from 'sharp'
import { epsg3857extent } from "src/constants/ems"
import { Coordinate, Extent } from "src/interfaces/map"
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
}
export async function generateTilesForZoomLevel(uploadDir: string, tileFolder: string, 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
Logger.log("sharping step 0")
Logger.log("initializing pixel paddings")
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
Logger.log("initializing pixel paddings")
if (!existsSync(join(tileFolder, 'custom', zoomLevel.toString()))) {
mkdirSync(join(tileFolder, 'custom', zoomLevel.toString()), { recursive: true })
Logger.log('created folder custom')
}
Logger.log(`sharping step 1 ${join(uploadDir, file.filename)}`)
const initialZoomImage = await sharp(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 })
Logger.log('sharping step 2')
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(join(tileFolder, 'custom', zoomLevel.toString(), zoomLevel.toString() + '.png'))
.then(async (res) => {
let left = 0
for (let x = minX; x <= maxX; x++) {
if (!existsSync(join(tileFolder, 'custom', zoomLevel.toString(), x.toString()))) {
mkdirSync(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(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(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)
}
}