Vladislav 2 months ago
commit a479fe3d98
  1. 30
      .gitignore
  2. 3
      .vscode/extensions.json
  3. 38
      README.md
  4. 22
      index.html
  5. 8
      jsconfig.json
  6. 3870
      package-lock.json
  7. 28
      package.json
  8. BIN
      public/favicon.ico
  9. 2654
      server/package-lock.json
  10. 22
      server/package.json
  11. 103
      server/routes/ndvi.js
  12. 28
      server/server.js
  13. 231
      server/utils/sentinel.js
  14. 82
      src/App.vue
  15. 397
      src/components/Dashboard.vue
  16. 91
      src/components/HistoryComponent.vue
  17. 470
      src/components/MapComponent.vue
  18. 5
      src/main.js
  19. 90
      src/styles/main.css
  20. 10
      tailwind.config.js
  21. 18
      vite.config.js

30
.gitignore vendored

@ -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"]
}

3870
package-lock.json generated

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"
}
}

Binary file not shown.

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,91 @@
<template>
<div class="bg-white rounded-lg shadow-lg p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">📋 История анализов</h2>
<button
@click="$emit('clear-history')"
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors"
:disabled="history.length === 0"
>
Очистить историю
</button>
</div>
<div v-if="history.length === 0" class="text-center py-12">
<div class="text-6xl mb-4">📊</div>
<h3 class="text-xl font-semibold text-gray-600 mb-2">История пуста</h3>
<p class="text-gray-500">Выполните анализ поля, чтобы сохранить его в историю</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="item in history"
:key="item.id"
class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow bg-white"
>
<div class="flex justify-between items-start mb-3">
<h3 class="font-semibold text-lg text-gray-800 truncate">{{ item.fieldName }}</h3>
<button
@click="$emit('delete-history', item.id)"
class="text-red-500 hover:text-red-700 transition-colors"
title="Удалить"
>
🗑
</button>
</div>
<div class="space-y-2 text-sm text-gray-600 mb-4">
<div class="flex justify-between">
<span>Дата анализа:</span>
<span class="font-medium">{{ new Date(item.date).toLocaleDateString() }}</span>
</div>
<div class="flex justify-between">
<span>Площадь:</span>
<span class="font-medium">{{ item.area }} га</span>
</div>
<div class="flex justify-between">
<span>Средний NDVI:</span>
<span :class="getNDVIClass(item.result.stats.mean)" class="font-medium">
{{ item.result.stats.mean }}
</span>
</div>
</div>
<div class="space-y-2 mb-4">
<div class="flex justify-between text-xs">
<span class="text-green-600">Здоровая: {{ item.result.stats.healthy }}%</span>
<span class="text-yellow-600">Умеренная: {{ item.result.stats.moderate }}%</span>
<span class="text-red-600">Слабая: {{ item.result.stats.poor }}%</span>
</div>
</div>
<button
@click="$emit('load-analysis', item)"
class="w-full bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-lg transition-colors flex items-center justify-center"
>
<span>Загрузить анализ</span>
</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'HistoryComponent',
props: {
history: {
type: Array,
default: () => []
}
},
emits: ['load-analysis', 'delete-history', 'clear-history'],
methods: {
getNDVIClass(ndvi) {
if (ndvi > 0.6) return 'text-green-600';
if (ndvi > 0.3) return 'text-yellow-600';
return 'text-red-600';
}
}
};
</script>

@ -0,0 +1,470 @@
<template>
<div class="map-container">
<div class="map-controls">
<div class="field-name-input">
<label>Название поля:</label>
<input
v-model="localFieldName"
@input="$emit('field-name-changed', localFieldName)"
type="text"
placeholder="Введите название поля..."
/>
</div>
<button
@click="toggleDrawing"
:class="['draw-btn', { active: isDrawing }]"
>
{{ isDrawing ? '✓ Завершить рисование' : '🖊 Нарисовать поле' }}
</button>
<button
@click="clearMap"
class="clear-btn"
:disabled="!hasPolygon"
>
🗑 Очистить
</button>
</div>
<div id="map" ref="mapContainer" class="map"></div>
<div v-if="isLoading" class="loading-overlay">
<div class="spinner"></div>
<p>Анализ спутниковых данных...</p>
</div>
<div class="legend">
<h4>Легенда NDVI</h4>
<div class="legend-items">
<div v-for="item in legendItems" :key="item.label" class="legend-item">
<div class="color-box" :style="{ backgroundColor: item.color }"></div>
<span>{{ item.label }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Исправление для иконок маркеров
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
export default {
name: 'MapComponent',
emits: ['areaSelected', 'field-name-changed'],
props: {
analysisResult: Object,
isLoading: Boolean,
fieldName: String
},
data() {
return {
map: null,
drawnItems: null,
isDrawing: false,
hasPolygon: false,
currentPolygon: null,
localFieldName: '',
ndviOverlay: null,
ndviImageLayer: null,
legendItems: [
{ label: 'Вода/Облака', color: '#8C8C8C' },
{ label: 'Почва', color: '#D2B48C' },
{ label: 'Слабая растительность', color: '#FFFF00' },
{ label: 'Умеренная растительность', color: '#00FF00' },
{ label: 'Густая растительность', color: '#006400' }
]
};
},
watch: {
fieldName(newVal) {
this.localFieldName = newVal;
},
analysisResult(newResult) {
if (newResult && newResult.ndviImage && this.currentPolygon) {
this.displayNDVIResults(newResult);
}
}
},
mounted() {
this.localFieldName = this.fieldName;
this.$nextTick(() => {
this.initMap();
});
},
methods: {
initMap() {
// Центрируем карту на сельскохозяйственных регионах России
this.map = L.map(this.$refs.mapContainer).setView([51.6755, 39.2084], 5);
// Добавление базового слоя
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(this.map);
// Инициализация слоя для рисования
this.drawnItems = new L.FeatureGroup();
this.map.addLayer(this.drawnItems);
// Слой для NDVI overlay
this.ndviOverlay = L.layerGroup().addTo(this.map);
setTimeout(() => {
this.map.invalidateSize();
}, 100);
},
displayNDVIResults(result) {
// Очищаем предыдущие результаты
if (this.ndviImageLayer) {
this.ndviOverlay.removeLayer(this.ndviImageLayer);
}
if (result.ndviImage && this.currentPolygon) {
// Получаем bounds из текущего полигона
const bounds = this.currentPolygon.getBounds();
// Создаем маску для ограничения изображения полигоном
this.createNDVIOverlay(result.ndviImage, bounds);
console.log('NDVI overlay добавлен на карту в пределах полигона');
}
},
createNDVIOverlay(imageUrl, bounds) {
// Создаем временный элемент для загрузки изображения
const img = new Image();
img.onload = () => {
// Создаем canvas для маскирования
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
// Рисуем изображение
ctx.drawImage(img, 0, 0);
// Создаем маску в форме полигона
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = 'black';
ctx.beginPath();
// Преобразуем координаты полигона в координаты canvas
const polyBounds = this.currentPolygon.getBounds();
const polyPoints = this.currentPolygon.getLatLngs()[0];
polyPoints.forEach((latlng, index) => {
const x = ((latlng.lng - polyBounds.getWest()) / (polyBounds.getEast() - polyBounds.getWest())) * canvas.width;
const y = canvas.height - ((latlng.lat - polyBounds.getSouth()) / (polyBounds.getNorth() - polyBounds.getSouth())) * canvas.height;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.closePath();
ctx.fill();
// Создаем Data URL для маскированного изображения
const maskedImageUrl = canvas.toDataURL('image/png');
// Добавляем маскированное изображение на карту
this.ndviImageLayer = L.imageOverlay(maskedImageUrl, bounds, {
opacity: 0.7,
interactive: false
}).addTo(this.ndviOverlay);
};
img.src = imageUrl;
},
toggleDrawing() {
if (this.isDrawing) {
this.finishDrawing();
} else {
this.startDrawing();
}
},
startDrawing() {
this.isDrawing = true;
this.map.dragging.disable();
this.map.getContainer().style.cursor = 'crosshair';
// Очищаем предыдущий полигон и результаты
this.clearNDVIResults();
if (this.currentPolygon) {
this.drawnItems.removeLayer(this.currentPolygon);
}
// Создаем новый полигон
this.currentPolygon = L.polygon([], {
color: '#3388ff',
fillColor: '#3388ff',
fillOpacity: 0.2,
weight: 3
}).addTo(this.drawnItems);
this.map.on('click', this.handleMapClick);
},
finishDrawing() {
this.isDrawing = false;
this.map.dragging.enable();
this.map.getContainer().style.cursor = '';
this.map.off('click', this.handleMapClick);
if (this.currentPolygon) {
const latlngs = this.currentPolygon.getLatLngs()[0];
// Проверяем, что полигон имеет достаточно точек
if (latlngs.length >= 3) {
// Замыкаем полигон
latlngs.push(latlngs[0]);
this.currentPolygon.setLatLngs([latlngs]);
const coordinates = this.getPolygonCoordinates(this.currentPolygon);
this.$emit('areaSelected', coordinates);
// Центрируем карту на нарисованном полигоне
this.map.fitBounds(this.currentPolygon.getBounds());
} else {
alert('Пожалуйста, нарисуйте полигон как минимум с 3 точками');
this.drawnItems.removeLayer(this.currentPolygon);
this.currentPolygon = null;
}
}
},
handleMapClick(e) {
if (this.currentPolygon) {
const latlngs = this.currentPolygon.getLatLngs()[0];
latlngs.push(e.latlng);
this.currentPolygon.setLatLngs([latlngs]);
this.hasPolygon = true;
}
},
getPolygonCoordinates(polygon) {
const latlngs = polygon.getLatLngs()[0];
const coordinates = latlngs.map(latlng => [latlng.lng, latlng.lat]);
return {
type: 'Polygon',
coordinates: [coordinates]
};
},
clearNDVIResults() {
if (this.ndviOverlay) {
this.ndviOverlay.clearLayers();
}
this.ndviImageLayer = null;
},
clearMap() {
if (this.drawnItems) {
this.drawnItems.clearLayers();
}
this.clearNDVIResults();
this.currentPolygon = null;
this.hasPolygon = false;
this.isDrawing = false;
this.map.off('click', this.handleMapClick);
this.map.getContainer().style.cursor = '';
this.map.dragging.enable();
// Возвращаем карту к исходному виду
this.map.setView([51.6755, 39.2084], 5);
}
},
beforeUnmount() {
if (this.map) {
this.map.remove();
}
}
};
</script>
<style scoped>
.map-container {
position: relative;
height: 600px;
width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
background: white;
}
.map {
height: 100%;
width: 100%;
min-height: 500px;
}
.map-controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
min-width: 250px;
}
.field-name-input {
display: flex;
flex-direction: column;
gap: 5px;
}
.field-name-input label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.field-name-input input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.field-name-input input:focus {
outline: none;
border-color: #3388ff;
}
.draw-btn, .clear-btn {
padding: 10px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
font-weight: 500;
}
.draw-btn {
background: #4CAF50;
color: white;
}
.draw-btn.active {
background: #2196F3;
}
.draw-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.clear-btn {
background: #f44336;
color: white;
}
.clear-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.clear-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.9);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 999;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.legend {
position: absolute;
bottom: 10px;
right: 10px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 1000;
max-width: 250px;
}
.legend h4 {
margin: 0 0 10px 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.legend-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.color-box {
width: 20px;
height: 20px;
border: 1px solid #ccc;
border-radius: 3px;
flex-shrink: 0;
}
.legend-item span {
color: #666;
line-height: 1.3;
}
</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…
Cancel
Save