commit
a479fe3d98
@ -0,0 +1,30 @@ |
|||||||
|
# Logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
pnpm-debug.log* |
||||||
|
lerna-debug.log* |
||||||
|
|
||||||
|
node_modules |
||||||
|
.DS_Store |
||||||
|
dist |
||||||
|
dist-ssr |
||||||
|
coverage |
||||||
|
*.local |
||||||
|
|
||||||
|
/cypress/videos/ |
||||||
|
/cypress/screenshots/ |
||||||
|
|
||||||
|
# Editor directories and files |
||||||
|
.vscode/* |
||||||
|
!.vscode/extensions.json |
||||||
|
.idea |
||||||
|
*.suo |
||||||
|
*.ntvs* |
||||||
|
*.njsproj |
||||||
|
*.sln |
||||||
|
*.sw? |
||||||
|
|
||||||
|
*.tsbuildinfo |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
{ |
||||||
|
"recommendations": ["Vue.volar"] |
||||||
|
} |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
# AIsentinel |
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. |
||||||
|
|
||||||
|
## Recommended IDE Setup |
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). |
||||||
|
|
||||||
|
## Recommended Browser Setup |
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.): |
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd) |
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters) |
||||||
|
- Firefox: |
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/) |
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/) |
||||||
|
|
||||||
|
## Customize configuration |
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/). |
||||||
|
|
||||||
|
## Project Setup |
||||||
|
|
||||||
|
```sh |
||||||
|
npm install |
||||||
|
``` |
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development |
||||||
|
|
||||||
|
```sh |
||||||
|
npm run dev |
||||||
|
``` |
||||||
|
|
||||||
|
### Compile and Minify for Production |
||||||
|
|
||||||
|
```sh |
||||||
|
npm run build |
||||||
|
``` |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="ru"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Анализатор здоровья полей</title> |
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" |
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" |
||||||
|
crossorigin=""/> |
||||||
|
<style> |
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
font-family: 'Arial', sans-serif; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="app"></div> |
||||||
|
<script type="module" src="/src/main.js"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"paths": { |
||||||
|
"@/*": ["./src/*"] |
||||||
|
} |
||||||
|
}, |
||||||
|
"exclude": ["node_modules", "dist"] |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,28 @@ |
|||||||
|
{ |
||||||
|
"name": "aisentinel", |
||||||
|
"version": "0.0.0", |
||||||
|
"private": true, |
||||||
|
"type": "module", |
||||||
|
"engines": { |
||||||
|
"node": "^20.19.0 || >=22.12.0" |
||||||
|
}, |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "vite build", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"axios": "^1.12.2", |
||||||
|
"leaflet": "^1.9.4", |
||||||
|
"vue": "^3.5.22" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@tailwindcss/postcss": "^4.1.14", |
||||||
|
"@vitejs/plugin-vue": "^6.0.1", |
||||||
|
"autoprefixer": "^10.4.21", |
||||||
|
"postcss": "^8.5.6", |
||||||
|
"tailwindcss": "^4.1.14", |
||||||
|
"vite": "^7.1.7", |
||||||
|
"vite-plugin-vue-devtools": "^8.0.2" |
||||||
|
} |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 4.2 KiB |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@ |
|||||||
|
{ |
||||||
|
"name": "field-health-backend", |
||||||
|
"version": "1.0.0", |
||||||
|
"description": "Backend for field health analysis using satellite imagery", |
||||||
|
"main": "server.js", |
||||||
|
"scripts": { |
||||||
|
"start": "node server.js", |
||||||
|
"dev": "nodemon server.js" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"axios": "^1.6.0", |
||||||
|
"canvas": "^3.2.0", |
||||||
|
"cors": "^2.8.5", |
||||||
|
"express": "^4.18.2", |
||||||
|
"geotiff": "^2.0.7", |
||||||
|
"multer": "^2.0.2", |
||||||
|
"turf": "^3.0.14" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"nodemon": "^3.0.1" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
import express from 'express'; |
||||||
|
import axios from 'axios'; |
||||||
|
import { getSentinelData, calculateNDVI, generateFieldPatternNDVI } from '../utils/sentinel.js'; |
||||||
|
|
||||||
|
const router = express.Router(); |
||||||
|
|
||||||
|
// Analyze field health
|
||||||
|
router.post('/analyze', async (req, res) => { |
||||||
|
try { |
||||||
|
const { polygon, date } = req.body; |
||||||
|
|
||||||
|
if (!polygon || !polygon.coordinates) { |
||||||
|
return res.status(400).json({ error: 'Polygon coordinates are required' }); |
||||||
|
} |
||||||
|
|
||||||
|
console.log('Analyzing polygon:', polygon.coordinates[0].length, 'points'); |
||||||
|
|
||||||
|
// Получаем спутниковые данные
|
||||||
|
const satelliteData = await getSentinelData(polygon, date); |
||||||
|
|
||||||
|
// Вычисляем NDVI
|
||||||
|
const ndviResult = await calculateNDVI(satelliteData); |
||||||
|
|
||||||
|
// Генерируем статистику
|
||||||
|
const stats = generateNDVIStats(ndviResult.ndviValues); |
||||||
|
|
||||||
|
// Для демонстрации используем сгенерированное изображение с паттернами
|
||||||
|
const demoImage = generateFieldPatternNDVI(400, 400); |
||||||
|
|
||||||
|
res.json({ |
||||||
|
success: true, |
||||||
|
data: { |
||||||
|
ndviImage: demoImage, // Используем демо изображение
|
||||||
|
stats: stats, |
||||||
|
date: satelliteData.date || new Date().toISOString().split('T')[0], |
||||||
|
area: calculateArea(polygon), |
||||||
|
polygon: polygon // Сохраняем полигон для истории
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Error in NDVI analysis:', error); |
||||||
|
res.status(500).json({
|
||||||
|
error: 'Analysis failed',
|
||||||
|
message: error.message
|
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Остальные функции остаются без изменений...
|
||||||
|
// Generate NDVI statistics
|
||||||
|
function generateNDVIStats(ndviValues) { |
||||||
|
const validValues = ndviValues.filter(v => !isNaN(v) && v >= -1 && v <= 1); |
||||||
|
|
||||||
|
if (validValues.length === 0) { |
||||||
|
return { |
||||||
|
mean: 0, |
||||||
|
min: 0, |
||||||
|
max: 0, |
||||||
|
healthy: 0, |
||||||
|
moderate: 0, |
||||||
|
poor: 0 |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const mean = validValues.reduce((a, b) => a + b, 0) / validValues.length; |
||||||
|
const min = Math.min(...validValues); |
||||||
|
const max = Math.max(...validValues); |
||||||
|
|
||||||
|
// Categorize vegetation health
|
||||||
|
const healthy = validValues.filter(v => v > 0.6).length / validValues.length * 100; |
||||||
|
const moderate = validValues.filter(v => v > 0.3 && v <= 0.6).length / validValues.length * 100; |
||||||
|
const poor = validValues.filter(v => v <= 0.3).length / validValues.length * 100; |
||||||
|
|
||||||
|
return { |
||||||
|
mean: parseFloat(mean.toFixed(3)), |
||||||
|
min: parseFloat(min.toFixed(3)), |
||||||
|
max: parseFloat(max.toFixed(3)), |
||||||
|
healthy: parseFloat(healthy.toFixed(1)), |
||||||
|
moderate: parseFloat(moderate.toFixed(1)), |
||||||
|
poor: parseFloat(poor.toFixed(1)) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Calculate area in hectares (simplified)
|
||||||
|
function calculateArea(polygon) { |
||||||
|
const coords = polygon.coordinates[0]; |
||||||
|
let area = 0; |
||||||
|
|
||||||
|
for (let i = 0; i < coords.length - 1; i++) { |
||||||
|
const [x1, y1] = coords[i]; |
||||||
|
const [x2, y2] = coords[i + 1]; |
||||||
|
area += (x1 * y2 - x2 * y1); |
||||||
|
} |
||||||
|
|
||||||
|
area = Math.abs(area) / 2; |
||||||
|
// Convert to hectares (approximate)
|
||||||
|
const areaHectares = area * 10000; |
||||||
|
|
||||||
|
return parseFloat(areaHectares.toFixed(2)); |
||||||
|
} |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
import express, { json, urlencoded } from 'express'; |
||||||
|
import cors from 'cors'; |
||||||
|
import ndviRoutes from './routes/ndvi.js'; |
||||||
|
|
||||||
|
const app = express(); |
||||||
|
const PORT = process.env.PORT || 3000; |
||||||
|
|
||||||
|
app.use(cors()); |
||||||
|
app.use(json({ limit: '50mb' })); |
||||||
|
app.use(urlencoded({ extended: true, limit: '50mb' })); |
||||||
|
|
||||||
|
app.use('/api/ndvi', ndviRoutes); |
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => { |
||||||
|
res.json({ status: 'OK', message: 'Работаю!' }); |
||||||
|
}); |
||||||
|
|
||||||
|
app.use((error, req, res, next) => { |
||||||
|
console.error('Ошибка:', error); |
||||||
|
res.status(500).json({ |
||||||
|
error: 'Internal server error', |
||||||
|
message: error.message |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
app.listen(PORT, () => { |
||||||
|
console.log(`Сервер работает на порту ${PORT}`); |
||||||
|
}); |
||||||
@ -0,0 +1,231 @@ |
|||||||
|
import { createCanvas } from 'canvas'; |
||||||
|
import axios from 'axios'; |
||||||
|
|
||||||
|
class SentinelHubService { |
||||||
|
constructor() { |
||||||
|
this.baseUrl = 'https://services.sentinel-hub.com/ogc/wms'; |
||||||
|
this.instanceId = process.env.SENTINEL_INSTANCE_ID; |
||||||
|
} |
||||||
|
|
||||||
|
async getData(polygon, date = null) { |
||||||
|
// Генерируем mock данные для демонстрации
|
||||||
|
const bbox = this.getBoundingBox(polygon); |
||||||
|
|
||||||
|
return { |
||||||
|
redBand: this.generateMockBandData(bbox, 0.1, 0.3), |
||||||
|
nirBand: this.generateMockBandData(bbox, 0.4, 0.8), |
||||||
|
date: date || new Date().toISOString().split('T')[0], |
||||||
|
bbox: bbox |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
getBoundingBox(polygon) { |
||||||
|
const coords = polygon.coordinates[0]; |
||||||
|
const lats = coords.map(coord => coord[1]); |
||||||
|
const lons = coords.map(coord => coord[0]); |
||||||
|
|
||||||
|
return { |
||||||
|
minLon: Math.min(...lons), |
||||||
|
minLat: Math.min(...lats), |
||||||
|
maxLon: Math.max(...lons), |
||||||
|
maxLat: Math.max(...lats) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
generateMockBandData(bbox, min, max) { |
||||||
|
// Генерируем mock растровые данные
|
||||||
|
const width = 200; |
||||||
|
const height = 200; |
||||||
|
const data = []; |
||||||
|
|
||||||
|
// Создаем паттерн для более реалистичных данных
|
||||||
|
for (let y = 0; y < height; y++) { |
||||||
|
const row = []; |
||||||
|
for (let x = 0; x < width; x++) { |
||||||
|
// Создаем паттерн с некоторой пространственной корреляцией
|
||||||
|
const baseValue = min + Math.random() * (max - min); |
||||||
|
|
||||||
|
// Добавляем некоторые паттерны
|
||||||
|
const pattern1 = Math.sin(x * 0.1) * 0.1; |
||||||
|
const pattern2 = Math.cos(y * 0.1) * 0.1; |
||||||
|
const noise = (Math.random() - 0.5) * 0.2; |
||||||
|
|
||||||
|
const value = Math.max(min, Math.min(max, baseValue + pattern1 + pattern2 + noise)); |
||||||
|
row.push(value); |
||||||
|
} |
||||||
|
data.push(row); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
data: data, |
||||||
|
width: width, |
||||||
|
height: height, |
||||||
|
bbox: bbox |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const sentinelService = new SentinelHubService(); |
||||||
|
|
||||||
|
export async function getSentinelData(polygon, date) { |
||||||
|
return await sentinelService.getData(polygon, date); |
||||||
|
} |
||||||
|
|
||||||
|
export async function calculateNDVI(satelliteData) { |
||||||
|
const { redBand, nirBand } = satelliteData; |
||||||
|
const ndviValues = []; |
||||||
|
|
||||||
|
// Создаем canvas для генерации изображения
|
||||||
|
const canvas = createCanvas(redBand.width, redBand.height); |
||||||
|
const ctx = canvas.getContext('2d'); |
||||||
|
|
||||||
|
// Создаем ImageData для манипуляций с пикселями
|
||||||
|
const imageData = ctx.createImageData(redBand.width, redBand.height); |
||||||
|
|
||||||
|
for (let y = 0; y < redBand.height; y++) { |
||||||
|
for (let x = 0; x < redBand.width; x++) { |
||||||
|
const nir = nirBand.data[y][x]; |
||||||
|
const red = redBand.data[y][x]; |
||||||
|
|
||||||
|
// Calculate NDVI
|
||||||
|
let ndvi = (nir - red) / (nir + red); |
||||||
|
|
||||||
|
// Обрабатываем случаи деления на ноль
|
||||||
|
if (isNaN(ndvi) || !isFinite(ndvi)) { |
||||||
|
ndvi = -1; |
||||||
|
} |
||||||
|
|
||||||
|
ndviValues.push(ndvi); |
||||||
|
|
||||||
|
// Convert NDVI to color
|
||||||
|
const color = ndviToColor(ndvi); |
||||||
|
const index = (y * redBand.width + x) * 4; |
||||||
|
|
||||||
|
imageData.data[index] = color[0]; // R
|
||||||
|
imageData.data[index + 1] = color[1]; // G
|
||||||
|
imageData.data[index + 2] = color[2]; // B
|
||||||
|
imageData.data[index + 3] = color[3]; // A
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Применяем ImageData к canvas
|
||||||
|
ctx.putImageData(imageData, 0, 0); |
||||||
|
|
||||||
|
// Конвертируем в base64
|
||||||
|
const ndviImage = canvas.toDataURL('image/png'); |
||||||
|
|
||||||
|
return {
|
||||||
|
ndviValues,
|
||||||
|
ndviImage, |
||||||
|
width: redBand.width, |
||||||
|
height: redBand.height, |
||||||
|
bbox: redBand.bbox |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function ndviToColor(ndvi) { |
||||||
|
// Convert NDVI value to RGBA color
|
||||||
|
if (ndvi < -0.2) return [140, 140, 140, 200]; // Gray - water/clouds
|
||||||
|
if (ndvi < 0) return [210, 180, 140, 200]; // Tan - soil
|
||||||
|
if (ndvi < 0.1) return [255, 255, 200, 200]; // Light yellow - very sparse
|
||||||
|
if (ndvi < 0.3) return [255, 255, 0, 200]; // Yellow - sparse vegetation
|
||||||
|
if (ndvi < 0.6) return [0, 255, 0, 200]; // Green - moderate vegetation
|
||||||
|
return [0, 100, 0, 200]; // Dark green - dense vegetation
|
||||||
|
} |
||||||
|
|
||||||
|
// Альтернативная функция для генерации градиентного изображения
|
||||||
|
export function generateGradientNDVI(width, height) { |
||||||
|
const canvas = createCanvas(width, height); |
||||||
|
const ctx = canvas.getContext('2d'); |
||||||
|
|
||||||
|
// Создаем горизонтальный градиент
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, width, 0); |
||||||
|
gradient.addColorStop(0.0, '#8C8C8C'); // Вода/Облака
|
||||||
|
gradient.addColorStop(0.2, '#D2B48C'); // Почва
|
||||||
|
gradient.addColorStop(0.4, '#FFFF00'); // Слабая растительность
|
||||||
|
gradient.addColorStop(0.7, '#00FF00'); // Умеренная растительность
|
||||||
|
gradient.addColorStop(1.0, '#006400'); // Густая растительность
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient; |
||||||
|
ctx.fillRect(0, 0, width, height); |
||||||
|
|
||||||
|
// Добавляем вертикальные вариации
|
||||||
|
const verticalGradient = ctx.createLinearGradient(0, 0, 0, height); |
||||||
|
verticalGradient.addColorStop(0, 'rgba(255,255,255,0.3)'); |
||||||
|
verticalGradient.addColorStop(1, 'rgba(0,0,0,0.3)'); |
||||||
|
|
||||||
|
ctx.fillStyle = verticalGradient; |
||||||
|
ctx.fillRect(0, 0, width, height); |
||||||
|
|
||||||
|
// Добавляем некоторые паттерны для реалистичности
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.1)'; |
||||||
|
for (let i = 0; i < 50; i++) { |
||||||
|
const x = Math.random() * width; |
||||||
|
const y = Math.random() * height; |
||||||
|
const size = Math.random() * 10 + 5; |
||||||
|
ctx.beginPath(); |
||||||
|
ctx.arc(x, y, size, 0, Math.PI * 2); |
||||||
|
ctx.fill(); |
||||||
|
} |
||||||
|
|
||||||
|
return canvas.toDataURL('image/png'); |
||||||
|
} |
||||||
|
|
||||||
|
// Функция для создания паттернов поля
|
||||||
|
export function generateFieldPatternNDVI(width, height) { |
||||||
|
const canvas = createCanvas(width, height); |
||||||
|
const ctx = canvas.getContext('2d'); |
||||||
|
|
||||||
|
// Заливаем базовым цветом (умеренная растительность)
|
||||||
|
ctx.fillStyle = '#00FF00'; |
||||||
|
ctx.fillRect(0, 0, width, height); |
||||||
|
|
||||||
|
// Добавляем полосы разной растительности
|
||||||
|
const stripeHeight = height / 8; |
||||||
|
for (let i = 0; i < 8; i++) { |
||||||
|
const y = i * stripeHeight; |
||||||
|
|
||||||
|
if (i % 4 === 0) { |
||||||
|
// Густая растительность
|
||||||
|
ctx.fillStyle = '#006400'; |
||||||
|
} else if (i % 4 === 2) { |
||||||
|
// Слабая растительность
|
||||||
|
ctx.fillStyle = '#FFFF00'; |
||||||
|
} else { |
||||||
|
// Умеренная растительность
|
||||||
|
ctx.fillStyle = '#00FF00'; |
||||||
|
} |
||||||
|
|
||||||
|
ctx.fillRect(0, y, width, stripeHeight); |
||||||
|
} |
||||||
|
|
||||||
|
// Добавляем случайные пятна проблемных зон
|
||||||
|
ctx.fillStyle = '#D2B48C'; |
||||||
|
for (let i = 0; i < 15; i++) { |
||||||
|
const x = Math.random() * width; |
||||||
|
const y = Math.random() * height; |
||||||
|
const size = Math.random() * 20 + 10; |
||||||
|
ctx.beginPath(); |
||||||
|
ctx.arc(x, y, size, 0, Math.PI * 2); |
||||||
|
ctx.fill(); |
||||||
|
} |
||||||
|
|
||||||
|
// Добавляем водные объекты
|
||||||
|
ctx.fillStyle = '#8C8C8C'; |
||||||
|
for (let i = 0; i < 3; i++) { |
||||||
|
const x = Math.random() * width * 0.8; |
||||||
|
const y = Math.random() * height * 0.8; |
||||||
|
const w = Math.random() * 30 + 20; |
||||||
|
const h = Math.random() * 30 + 20; |
||||||
|
ctx.fillRect(x, y, w, h); |
||||||
|
} |
||||||
|
|
||||||
|
return canvas.toDataURL('image/png'); |
||||||
|
} |
||||||
|
|
||||||
|
export default { |
||||||
|
getSentinelData, |
||||||
|
calculateNDVI, |
||||||
|
generateGradientNDVI, |
||||||
|
generateFieldPatternNDVI |
||||||
|
}; |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
<template> |
||||||
|
<div id="app"> |
||||||
|
<header class="header"> |
||||||
|
<div class="container"> |
||||||
|
<h1>🌱 Анализатор здоровья полей</h1> |
||||||
|
<p>AI-анализ состояния сельскохозяйственных культур по спутниковым снимкам</p> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main class="main"> |
||||||
|
<div class="container"> |
||||||
|
<div class="layout"> |
||||||
|
<div class="map-section"> |
||||||
|
<MapComponent |
||||||
|
@area-selected="handleAreaSelected" |
||||||
|
:analysis-result="analysisResult" |
||||||
|
:is-loading="isLoading" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="dashboard-section"> |
||||||
|
<Dashboard |
||||||
|
:stats="analysisResult?.stats" |
||||||
|
:date="analysisResult?.date" |
||||||
|
:area="analysisResult?.area" |
||||||
|
:is-loading="isLoading" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</main> |
||||||
|
|
||||||
|
<footer class="footer"> |
||||||
|
<div class="container"> |
||||||
|
<p>© 2024 Field Health Analyzer. Хакатон проект.</p> |
||||||
|
</div> |
||||||
|
</footer> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script> |
||||||
|
import MapComponent from './components/MapComponent.vue'; |
||||||
|
import Dashboard from './components/Dashboard.vue'; |
||||||
|
import axios from 'axios'; |
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:3000/api'; |
||||||
|
|
||||||
|
export default { |
||||||
|
name: 'App', |
||||||
|
components: { |
||||||
|
MapComponent, |
||||||
|
Dashboard |
||||||
|
}, |
||||||
|
data() { |
||||||
|
return { |
||||||
|
analysisResult: null, |
||||||
|
isLoading: false |
||||||
|
}; |
||||||
|
}, |
||||||
|
methods: { |
||||||
|
async handleAreaSelected(polygon) { |
||||||
|
this.isLoading = true; |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await axios.post(`${API_BASE}/ndvi/analyze`, { |
||||||
|
polygon: polygon, |
||||||
|
date: new Date().toISOString().split('T')[0] |
||||||
|
}); |
||||||
|
|
||||||
|
if (response.data.success) { |
||||||
|
this.analysisResult = response.data.data; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error analyzing area:', error); |
||||||
|
alert('Ошибка при анализе области. Попробуйте еще раз.'); |
||||||
|
} finally { |
||||||
|
this.isLoading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
</script> |
||||||
@ -0,0 +1,397 @@ |
|||||||
|
<template> |
||||||
|
<div class="dashboard"> |
||||||
|
<div class="dashboard-header"> |
||||||
|
<h2>📊 Анализ здоровья поля</h2> |
||||||
|
<p v-if="!stats">Выберите область на карте для анализа</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div v-if="isLoading" class="loading"> |
||||||
|
<div class="spinner"></div> |
||||||
|
<p>Идет анализ данных...</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div v-else-if="stats" class="dashboard-content"> |
||||||
|
<div class="info-cards"> |
||||||
|
<div class="info-card"> |
||||||
|
<div class="card-icon">📅</div> |
||||||
|
<div class="card-content"> |
||||||
|
<h3>Дата анализа</h3> |
||||||
|
<p>{{ date }}</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="info-card"> |
||||||
|
<div class="card-icon">📐</div> |
||||||
|
<div class="card-content"> |
||||||
|
<h3>Площадь поля</h3> |
||||||
|
<p>{{ area }} га</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="info-card"> |
||||||
|
<div class="card-icon">📈</div> |
||||||
|
<div class="card-content"> |
||||||
|
<h3>Средний NDVI</h3> |
||||||
|
<p :class="getNDVIClass(stats.mean)">{{ stats.mean }}</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="stats-section"> |
||||||
|
<h3>Статистика растительности</h3> |
||||||
|
|
||||||
|
<div class="health-stats"> |
||||||
|
<div class="health-item good"> |
||||||
|
<div class="health-bar"> |
||||||
|
<div |
||||||
|
class="health-fill" |
||||||
|
:style="{ width: stats.healthy + '%' }" |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
<div class="health-label"> |
||||||
|
<span>Здоровая</span> |
||||||
|
<span>{{ stats.healthy }}%</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-item moderate"> |
||||||
|
<div class="health-bar"> |
||||||
|
<div |
||||||
|
class="health-fill" |
||||||
|
:style="{ width: stats.moderate + '%' }" |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
<div class="health-label"> |
||||||
|
<span>Умеренная</span> |
||||||
|
<span>{{ stats.moderate }}%</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-item poor"> |
||||||
|
<div class="health-bar"> |
||||||
|
<div |
||||||
|
class="health-fill" |
||||||
|
:style="{ width: stats.poor + '%' }" |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
<div class="health-label"> |
||||||
|
<span>Слабая</span> |
||||||
|
<span>{{ stats.poor }}%</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="ndvi-info"> |
||||||
|
<h3>Интерпретация NDVI</h3> |
||||||
|
<div class="ndvi-scale"> |
||||||
|
<div class="scale-item" v-for="item in ndviScale" :key="item.label"> |
||||||
|
<div class="scale-color" :style="{ background: item.color }"></div> |
||||||
|
<span class="scale-label">{{ item.label }}</span> |
||||||
|
<span class="scale-range">{{ item.range }}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="recommendations" v-if="stats"> |
||||||
|
<h3>Рекомендации</h3> |
||||||
|
<div class="recommendation-list"> |
||||||
|
<div |
||||||
|
v-for="rec in getRecommendations()" |
||||||
|
:key="rec.text" |
||||||
|
class="recommendation-item" |
||||||
|
> |
||||||
|
<span class="rec-icon">{{ rec.icon }}</span> |
||||||
|
<span>{{ rec.text }}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div v-else class="empty-state"> |
||||||
|
<div class="empty-icon">🌾</div> |
||||||
|
<h3>Начните анализ</h3> |
||||||
|
<p>Нарисуйте полигон на карте, чтобы проанализировать здоровье растительности</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script> |
||||||
|
export default { |
||||||
|
name: 'Dashboard', |
||||||
|
props: { |
||||||
|
stats: Object, |
||||||
|
date: String, |
||||||
|
area: Number, |
||||||
|
isLoading: Boolean |
||||||
|
}, |
||||||
|
data() { |
||||||
|
return { |
||||||
|
ndviScale: [ |
||||||
|
{ label: 'Густая растительность', range: '0.6 - 1.0', color: '#006400' }, |
||||||
|
{ label: 'Умеренная растительность', range: '0.3 - 0.6', color: '#00FF00' }, |
||||||
|
{ label: 'Слабая растительность', range: '0.1 - 0.3', color: '#FFFF00' }, |
||||||
|
{ label: 'Почва', range: '0.0 - 0.1', color: '#D2B48C' }, |
||||||
|
{ label: 'Вода/Облака', range: '-1.0 - 0.0', color: '#8C8C8C' } |
||||||
|
] |
||||||
|
}; |
||||||
|
}, |
||||||
|
methods: { |
||||||
|
getNDVIClass(ndvi) { |
||||||
|
if (ndvi > 0.6) return 'ndvi-good'; |
||||||
|
if (ndvi > 0.3) return 'ndvi-moderate'; |
||||||
|
return 'ndvi-poor'; |
||||||
|
}, |
||||||
|
|
||||||
|
getRecommendations() { |
||||||
|
if (!this.stats) return []; |
||||||
|
|
||||||
|
const recommendations = []; |
||||||
|
|
||||||
|
if (this.stats.poor > 30) { |
||||||
|
recommendations.push({ |
||||||
|
icon: '💧', |
||||||
|
text: 'Рекомендуется дополнительный полив проблемных зон' |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
if (this.stats.mean < 0.4) { |
||||||
|
recommendations.push({ |
||||||
|
icon: '🌱', |
||||||
|
text: 'Рассмотрите внесение удобрений для улучшения вегетации' |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
if (this.stats.healthy > 70) { |
||||||
|
recommendations.push({ |
||||||
|
icon: '✅', |
||||||
|
text: 'Состояние поля отличное, продолжайте текущий уход' |
||||||
|
}); |
||||||
|
} else { |
||||||
|
recommendations.push({ |
||||||
|
icon: '🔍', |
||||||
|
text: 'Рекомендуется детальный осмотр проблемных участков' |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return recommendations; |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style scoped> |
||||||
|
.dashboard { |
||||||
|
background: white; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 20px; |
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
||||||
|
height: fit-content; |
||||||
|
} |
||||||
|
|
||||||
|
.dashboard-header h2 { |
||||||
|
margin: 0 0 8px 0; |
||||||
|
color: #2c3e50; |
||||||
|
} |
||||||
|
|
||||||
|
.dashboard-header p { |
||||||
|
margin: 0; |
||||||
|
color: #7f8c8d; |
||||||
|
} |
||||||
|
|
||||||
|
.loading { |
||||||
|
text-align: center; |
||||||
|
padding: 40px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.spinner { |
||||||
|
border: 4px solid #f3f3f3; |
||||||
|
border-top: 4px solid #3498db; |
||||||
|
border-radius: 50%; |
||||||
|
width: 40px; |
||||||
|
height: 40px; |
||||||
|
animation: spin 2s linear infinite; |
||||||
|
margin: 0 auto 16px; |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes spin { |
||||||
|
0% { transform: rotate(0deg); } |
||||||
|
100% { transform: rotate(360deg); } |
||||||
|
} |
||||||
|
|
||||||
|
.info-cards { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 1fr 1fr 1fr; |
||||||
|
gap: 15px; |
||||||
|
margin: 20px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.info-card { |
||||||
|
background: #f8f9fa; |
||||||
|
padding: 15px; |
||||||
|
border-radius: 8px; |
||||||
|
border-left: 4px solid #3498db; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 12px; |
||||||
|
} |
||||||
|
|
||||||
|
.card-icon { |
||||||
|
font-size: 24px; |
||||||
|
} |
||||||
|
|
||||||
|
.card-content h3 { |
||||||
|
margin: 0 0 4px 0; |
||||||
|
font-size: 14px; |
||||||
|
color: #7f8c8d; |
||||||
|
} |
||||||
|
|
||||||
|
.card-content p { |
||||||
|
margin: 0; |
||||||
|
font-size: 18px; |
||||||
|
font-weight: bold; |
||||||
|
color: #2c3e50; |
||||||
|
} |
||||||
|
|
||||||
|
.ndvi-good { color: #27ae60; } |
||||||
|
.ndvi-moderate { color: #f39c12; } |
||||||
|
.ndvi-poor { color: #e74c3c; } |
||||||
|
|
||||||
|
.stats-section { |
||||||
|
margin: 25px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.stats-section h3 { |
||||||
|
margin-bottom: 15px; |
||||||
|
color: #2c3e50; |
||||||
|
} |
||||||
|
|
||||||
|
.health-stats { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 12px; |
||||||
|
} |
||||||
|
|
||||||
|
.health-item { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 6px; |
||||||
|
} |
||||||
|
|
||||||
|
.health-bar { |
||||||
|
height: 8px; |
||||||
|
background: #ecf0f1; |
||||||
|
border-radius: 4px; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.health-fill { |
||||||
|
height: 100%; |
||||||
|
transition: width 0.5s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.health-item.good .health-fill { background: #27ae60; } |
||||||
|
.health-item.moderate .health-fill { background: #f39c12; } |
||||||
|
.health-item.poor .health-fill { background: #e74c3c; } |
||||||
|
|
||||||
|
.health-label { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
font-size: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
.ndvi-info { |
||||||
|
margin: 25px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.ndvi-info h3 { |
||||||
|
margin-bottom: 15px; |
||||||
|
color: #2c3e50; |
||||||
|
} |
||||||
|
|
||||||
|
.ndvi-scale { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
.scale-item { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 12px; |
||||||
|
padding: 4px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.scale-color { |
||||||
|
width: 20px; |
||||||
|
height: 20px; |
||||||
|
border-radius: 3px; |
||||||
|
border: 1px solid #ddd; |
||||||
|
} |
||||||
|
|
||||||
|
.scale-label { |
||||||
|
flex: 1; |
||||||
|
font-size: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
.scale-range { |
||||||
|
font-size: 12px; |
||||||
|
color: #7f8c8d; |
||||||
|
} |
||||||
|
|
||||||
|
.recommendations { |
||||||
|
margin-top: 25px; |
||||||
|
} |
||||||
|
|
||||||
|
.recommendations h3 { |
||||||
|
margin-bottom: 15px; |
||||||
|
color: #2c3e50; |
||||||
|
} |
||||||
|
|
||||||
|
.recommendation-list { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 10px; |
||||||
|
} |
||||||
|
|
||||||
|
.recommendation-item { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 10px; |
||||||
|
padding: 10px; |
||||||
|
background: #e8f4fd; |
||||||
|
border-radius: 6px; |
||||||
|
border-left: 4px solid #3498db; |
||||||
|
} |
||||||
|
|
||||||
|
.rec-icon { |
||||||
|
font-size: 18px; |
||||||
|
} |
||||||
|
|
||||||
|
.empty-state { |
||||||
|
text-align: center; |
||||||
|
padding: 40px 20px; |
||||||
|
color: #7f8c8d; |
||||||
|
} |
||||||
|
|
||||||
|
.empty-icon { |
||||||
|
font-size: 48px; |
||||||
|
margin-bottom: 16px; |
||||||
|
} |
||||||
|
|
||||||
|
.empty-state h3 { |
||||||
|
margin: 0 0 8px 0; |
||||||
|
color: #2c3e50; |
||||||
|
} |
||||||
|
|
||||||
|
.empty-state p { |
||||||
|
margin: 0; |
||||||
|
line-height: 1.5; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 768px) { |
||||||
|
.info-cards { |
||||||
|
grid-template-columns: 1fr; |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
import { createApp } from 'vue'; |
||||||
|
import App from './App.vue'; |
||||||
|
import './styles/main.css'; |
||||||
|
|
||||||
|
createApp(App).mount('#app'); |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
* { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
||||||
|
line-height: 1.6; |
||||||
|
color: #333; |
||||||
|
background-color: #f5f5f5; |
||||||
|
} |
||||||
|
|
||||||
|
/* Важные стили для Leaflet */ |
||||||
|
.leaflet-container { |
||||||
|
height: 100%; |
||||||
|
width: 100%; |
||||||
|
font-family: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
/* Исправление для тайлов */ |
||||||
|
.leaflet-layer, |
||||||
|
.leaflet-control-zoom-in, |
||||||
|
.leaflet-control-zoom-out, |
||||||
|
.leaflet-control-attribution { |
||||||
|
font-family: inherit !important; |
||||||
|
} |
||||||
|
|
||||||
|
/* Исправление для маркеров */ |
||||||
|
.leaflet-marker-icon { |
||||||
|
margin-left: -12px !important; |
||||||
|
margin-top: -41px !important; |
||||||
|
} |
||||||
|
|
||||||
|
.leaflet-marker-shadow { |
||||||
|
margin-left: -12px !important; |
||||||
|
margin-top: -41px !important; |
||||||
|
} |
||||||
|
|
||||||
|
.container { |
||||||
|
max-width: 1200px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 0 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.header { |
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||||
|
color: white; |
||||||
|
padding: 2rem 0; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.header h1 { |
||||||
|
font-size: 2.5rem; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.header p { |
||||||
|
font-size: 1.1rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
.main { |
||||||
|
padding: 2rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.layout { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 2fr 1fr; |
||||||
|
gap: 2rem; |
||||||
|
align-items: start; |
||||||
|
} |
||||||
|
|
||||||
|
.footer { |
||||||
|
background: #2c3e50; |
||||||
|
color: white; |
||||||
|
text-align: center; |
||||||
|
padding: 1rem 0; |
||||||
|
margin-top: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 768px) { |
||||||
|
.layout { |
||||||
|
grid-template-columns: 1fr; |
||||||
|
} |
||||||
|
|
||||||
|
.header h1 { |
||||||
|
font-size: 2rem; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
export default { |
||||||
|
content: [ |
||||||
|
"./index.html", |
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}", |
||||||
|
], |
||||||
|
theme: { |
||||||
|
extend: {}, |
||||||
|
}, |
||||||
|
plugins: [], |
||||||
|
} |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
import { fileURLToPath, URL } from 'node:url' |
||||||
|
|
||||||
|
import { defineConfig } from 'vite' |
||||||
|
import vue from '@vitejs/plugin-vue' |
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools' |
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({ |
||||||
|
plugins: [ |
||||||
|
vue(), |
||||||
|
vueDevTools(), |
||||||
|
], |
||||||
|
resolve: { |
||||||
|
alias: { |
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)) |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
Loading…
Reference in new issue