Compare commits

...

3 commits

Author SHA1 Message Date
ThePetrovich
0e4d5a8d47 Merge remote-tracking branch 'origin/velocity' into components 2025-07-02 19:17:45 +08:00
Vasilisk9812
aa0ff91a7d heatmap 2025-06-30 02:50:17 +09:00
Vasilisk9812
74340cf28e wind view checkbox 2025-06-27 22:15:02 +09:00
5 changed files with 365 additions and 57 deletions

15
package-lock.json generated
View file

@ -13,6 +13,7 @@
"bootstrap-icons": "^1.13.1",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"leaflet-heatmap": "^1.0.0",
"leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0"
},
@ -1078,6 +1079,11 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/heatmap.js": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/heatmap.js/-/heatmap.js-2.0.5.tgz",
"integrity": "sha512-CG2gYFP5Cv9IQCXEg3ZRxnJDyAilhWnQlAuHYGuWVzv6mFtQelS1bR9iN80IyDmFECbFPbg6I0LR5uAFHgCthw=="
},
"node_modules/import-meta-resolve": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
@ -1118,6 +1124,15 @@
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"node_modules/leaflet-heatmap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/leaflet-heatmap/-/leaflet-heatmap-1.0.0.tgz",
"integrity": "sha512-WP/emZYwjWaEnWMcE2dftuJvtjp53zmJcHtVTHUqPN7AQEowHxDTLH5j1BJjE4uL1K5dJclBLX4oLpnOGS/qTw==",
"dependencies": {
"heatmap.js": "*",
"leaflet": "*"
}
},
"node_modules/leaflet-velocity": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/leaflet-velocity/-/leaflet-velocity-2.1.4.tgz",

View file

@ -27,6 +27,7 @@
"bootstrap-icons": "^1.13.1",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"leaflet-heatmap": "^1.0.0",
"leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0"
}

View file

@ -20,32 +20,50 @@
// Функция для нормализации данных тепловой карты
const prepareHeatData = (windData) => {
if (!windData || !windData.header || !windData.data) {
console.warn("Wind data is missing or incomplete");
if (!windData || windData.length < 2) {
console.warn("Invalid wind data structure");
return [];
}
const { lo1, la1, dx, dy, nx, ny } = windData.header;
// Получаем U и V компоненты
const uComponent = windData.find(item => item.header.parameterNumber === 2);
const vComponent = windData.find(item => item.header.parameterNumber === 3);
if (!uComponent || !vComponent) {
console.warn("Missing wind components");
return [];
}
const header = uComponent.header; // Используем header из U компоненты
const { lo1, la1, dx, dy, nx, ny } = header;
const heatData = [];
let maxSpeed = 0;
// Проверяем совпадение размеров данных
if (uComponent.data.length !== vComponent.data.length) {
console.warn("U and V components have different lengths");
return [];
}
// Собираем данные и находим максимальную скорость
for (let y = 0; y < ny; y++) {
for (let x = 0; x < nx; x++) {
const u = windData.data[y][x * 2];
const v = windData.data[y][x * 2 + 1];
for (let i = 0; i < uComponent.data.length; i++) {
const u = uComponent.data[i];
const v = vComponent.data[i];
const speed = Math.sqrt(u * u + v * v);
if (!isNaN(speed)) {
// Вычисляем координаты для текущей точки
const y = Math.floor(i / nx);
const x = i % nx;
const lat = la1 - y * dy;
const lng = lo1 + x * dx;
heatData.push([lat, lng, speed]);
maxSpeed = Math.max(maxSpeed, speed);
}
}
}
console.log('Prepared heat data: ${heatData.length} points, max speed: ${maxSpeed}');
console.log(`Prepared heat data: ${heatData.length} points, max speed: ${maxSpeed}`);
// Нормализуем значения интенсивности от 0 до 1
if (maxSpeed > 0) {
@ -64,10 +82,10 @@
try {
return L.heatLayer(data, {
radius: 15,
blur: 20,
maxZoom: 17,
minOpacity: 0.5,
radius: 20, // Увеличьте радиус для глобальной карты
blur: 15,
maxZoom: 10,
minOpacity: 0.7,
gradient: {
0.1: 'blue',
0.3: 'cyan',
@ -92,6 +110,7 @@
if (legend) map.removeControl(legend);
// Создаем слой векторов ветра
if (showVectors) {
velocityLayer = L.velocityLayer({
displayValues: true,
displayOptions: {
@ -101,19 +120,43 @@
},
data: windData
}).addTo(map);
}
// Создаем тепловую карту
if (showHeatmap) {
const heatData = prepareHeatData(windData);
heatLayer = createHeatLayer(heatData);
if (heatLayer) {
heatLayer.addTo(map);
createLegend(Math.max(...heatData.map(point => point[2])));
} else {
console.warn("Heat layer was not created");
}
}
// Обновляем контроль слоев
updateLayerControl();
};
const updateLayerControl = () => {
if (layerControl) {
map.removeControl(layerControl);
}
const overlays = {};
if (velocityLayer) {
overlays['Векторы ветра'] = velocityLayer;
}
if (heatLayer) {
overlays['Тепловая карта'] = heatLayer;
}
layerControl = L.control.layers(null, overlays, {
collapsed: false,
position: 'topright'
}).addTo(map);
};
// Создание легенды с учетом максимальной скорости
const createLegend = (maxSpeed) => {
if (!map) return;
@ -162,8 +205,42 @@
updateLayers();
};
</script>
<div class="layer-controls">
<div class="control-group">
<label>
<input type="checkbox" bind:checked={showHeatmap}> Тепловая карта
</label>
<label>
<input type="checkbox" bind:checked={showVectors}> Векторы ветра
</label>
</div>
</div>
<style>
.wind-heat-legend {
.layer-controls {
position: absolute;
bottom: 30px;
left: 10px;
z-index: 1000;
background: rgba(255, 255, 255, 0.8);
padding: 10px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
cursor: pointer;
}
:global(.wind-heat-legend) {
padding: 8px 10px;
background: rgba(255, 255, 255, 0.9);
border-radius: 5px;
@ -173,23 +250,23 @@
font-family: Arial, sans-serif;
}
.wind-heat-legend h4 {
:global(.wind-heat-legend h4) {
margin: 0 0 5px;
font-size: 14px;
font-weight: bold;
}
.legend-scale {
:global(legend-scale) {
display: flex;
margin-bottom: 3px;
}
.legend-color {
:global(legend-color) {
height: 12px;
flex-grow: 1;
}
.legend-labels {
:global(.legend-labels) {
display: flex;
justify-content: space-between;
font-size: 11px;

215
src/routes/TimeLine.svelte Normal file
View file

@ -0,0 +1,215 @@
<!-- Timeline.svelte -->
<script>
import { onMount, onDestroy } from 'svelte';
export let initialHour = 0;
export let totalHours = 72; // 3 суток
export let updateInterval = 3; // шаг в 3 часа
export let playSpeed = 2000; // скорость анимации (мс)
let currentHour = initialHour;
let isPlaying = false;
let timer;
// События для коммуникации с родительским компонентом
const dispatch = createEventDispatcher();
// Автоматическое проигрывание
function togglePlay() {
isPlaying = !isPlaying;
if (isPlaying) {
timer = setInterval(() => {
currentHour = (currentHour + updateInterval) % totalHours;
dispatchTimeUpdate();
}, playSpeed);
} else {
clearInterval(timer);
}
}
function dispatchTimeUpdate() {
dispatch('timeupdate', {
hour: currentHour,
timestamp: calculateTimestamp(currentHour)
});
}
function calculateTimestamp(hour) {
const now = new Date();
now.setHours(now.getHours() + hour);
return now;
}
function formatTime(hours) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
const time = calculateTimestamp(hours);
return `+${days}d ${remainingHours}h (${time.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})})`;
}
// Очистка при размонтировании
onDestroy(() => {
clearInterval(timer);
});
</script>
<div class="timeline-container">
<div class="timeline-controls">
<button
on:click={() => {
currentHour = Math.max(0, currentHour - updateInterval);
dispatchTimeUpdate();
}}
disabled={currentHour <= 0}
class="control-button"
>
← Назад
</button>
<button
on:click={togglePlay}
class="control-button play-button {isPlaying ? 'playing' : ''}"
>
{isPlaying ? '❚❚' : '▶'}
</button>
<button
on:click={() => {
currentHour = Math.min(totalHours, currentHour + updateInterval);
dispatchTimeUpdate();
}}
disabled={currentHour >= totalHours}
class="control-button"
>
Вперёд →
</button>
<div class="time-display">
{formatTime(currentHour)}
</div>
</div>
<input
type="range"
min="0"
max={totalHours}
step={updateInterval}
bind:value={currentHour}
on:input={() => dispatchTimeUpdate()}
class="timeline-slider"
/>
<div class="time-marks">
{#each Array(Math.floor(totalHours / updateInterval) + 1) as _, i}
<div
class="time-mark {currentHour === i * updateInterval ? 'active' : ''}"
on:click={() => {
currentHour = i * updateInterval;
dispatchTimeUpdate();
}}
>
{i * updateInterval % 24 === 0 ? `День ${Math.floor(i * updateInterval / 24) + 1}` : ''}
</div>
{/each}
</div>
</div>
<style>
.timeline-container {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 800px;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
padding: 10px 15px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1000;
}
.timeline-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.control-button {
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
transition: all 0.2s;
}
.control-button:hover {
background: #e0e0e0;
}
.control-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.play-button {
width: 40px;
background: #4CAF50;
color: white;
border: none;
}
.play-button.playing {
background: #f44336;
}
.time-display {
font-weight: bold;
min-width: 180px;
text-align: center;
font-family: monospace;
}
.timeline-slider {
width: 100%;
margin: 5px 0;
cursor: pointer;
}
.time-marks {
display: flex;
justify-content: space-between;
margin-top: 5px;
}
.time-mark {
cursor: pointer;
padding: 2px 5px;
font-size: 12px;
position: relative;
}
.time-mark:before {
content: '';
position: absolute;
top: -15px;
left: 50%;
width: 1px;
height: 10px;
background: #ccc;
}
.time-mark.active {
font-weight: bold;
color: #2196F3;
}
.time-mark.active:before {
background: #2196F3;
height: 15px;
}
</style>