963 lines
39 KiB
HTML
963 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{.Title}}</title>
|
||
<script src="/static/js/chart.js"></script>
|
||
<link rel="stylesheet" href="/static/css/ol.css">
|
||
<script src="/static/js/ol.js"></script>
|
||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||
<link rel="stylesheet" href="/static/css/tailwind.min.css">
|
||
<style>
|
||
body {
|
||
font-family: Arial, sans-serif;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.px-7p {
|
||
padding-left: 7%;
|
||
padding-right: 7%;
|
||
}
|
||
|
||
.content-narrow {
|
||
width: 86%;
|
||
max-width: 1200px;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.content-narrow {
|
||
width: 92%;
|
||
}
|
||
}
|
||
|
||
.header {
|
||
padding: 10px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #ddd;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 15px;
|
||
}
|
||
|
||
.controls {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
padding: 15px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background-color: #fff;
|
||
}
|
||
|
||
.control-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
flex-wrap: wrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.station-input-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#stationInput {
|
||
width: 120px;
|
||
padding: 5px 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
}
|
||
|
||
select, input, button {
|
||
padding: 5px 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
button {
|
||
background-color: #007bff;
|
||
color: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: background-color 0.3s;
|
||
}
|
||
|
||
button:hover {
|
||
background-color: #0056b3;
|
||
}
|
||
|
||
button:disabled {
|
||
background-color: #ccc;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.map-container {
|
||
height: 60vh;
|
||
margin-bottom: 20px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
position: relative;
|
||
transition: height 0.3s ease;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.map-container.collapsed {
|
||
height: 38vh;
|
||
}
|
||
|
||
#map {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.map-controls {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
z-index: 1000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.map-control-btn {
|
||
padding: 3px 8px;
|
||
background-color: rgba(255, 255, 255, 0.9);
|
||
border: 1px solid #ddd;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
color: #666;
|
||
}
|
||
|
||
.map-control-btn:hover {
|
||
background-color: white;
|
||
}
|
||
|
||
.map-control-btn.active {
|
||
background-color: #007bff;
|
||
color: white;
|
||
}
|
||
|
||
.map-toggle-btn {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
z-index: 1001;
|
||
border-radius: 4px;
|
||
padding: 5px 10px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
color: white;
|
||
background-color: #007bff;
|
||
}
|
||
|
||
.station-info-title {
|
||
text-align: center;
|
||
margin-bottom: 15px;
|
||
padding: 10px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
|
||
.chart-container {
|
||
margin-bottom: 20px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
padding: 15px;
|
||
background-color: #fff;
|
||
display: none;
|
||
}
|
||
|
||
.chart-container.show {
|
||
display: block;
|
||
animation: slideDown 0.3s ease;
|
||
}
|
||
|
||
.chart-wrapper {
|
||
height: 500px;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.table-container {
|
||
overflow-x: auto;
|
||
margin-top: 20px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background-color: #fff;
|
||
display: none;
|
||
}
|
||
|
||
.table-container.show {
|
||
display: block;
|
||
animation: slideDown 0.3s ease;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
th, td {
|
||
border: 1px solid #ddd;
|
||
padding: 12px 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
th {
|
||
background-color: #f8f9fa;
|
||
font-weight: bold;
|
||
}
|
||
|
||
tr:nth-child(even) {
|
||
background-color: #f9f9f9;
|
||
}
|
||
|
||
tr:hover {
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
/* Radar view image normalization */
|
||
.radar-grid .img-wrap {
|
||
position: relative;
|
||
width: 75%;
|
||
margin: 0 auto;
|
||
aspect-ratio: 4/3;
|
||
background: #fafafa;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.radar-grid .img-wrap { min-height: 220px; }
|
||
@media (min-width: 768px) { .radar-grid .img-wrap { min-height: 320px; } }
|
||
@media (min-width: 1024px) { .radar-grid .img-wrap { min-height: 360px; } }
|
||
.radar-grid .img-wrap img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
|
||
.system-info {
|
||
background-color: #e9ecef;
|
||
padding: 10px;
|
||
margin-bottom: 20px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.device-modal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.3);
|
||
z-index: 2000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.device-modal-content {
|
||
position: relative;
|
||
background-color: #fff;
|
||
height: auto;
|
||
max-height: 70vh;
|
||
width: 90%;
|
||
max-width: 720px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.device-list-header {
|
||
padding: 15px 20px;
|
||
border-bottom: 1px solid #eee;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.device-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px 0;
|
||
position: relative;
|
||
}
|
||
|
||
.device-list-footer {
|
||
padding: 10px;
|
||
border-top: 1px solid #eee;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #fff;
|
||
}
|
||
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.pagination-btn {
|
||
padding: 4px 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 3px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.pagination-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.device-item {
|
||
padding: 12px 20px;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.device-item:hover {
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
.device-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.close-modal {
|
||
position: absolute;
|
||
right: 20px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background-color: #f5f5f5;
|
||
cursor: pointer;
|
||
color: #666;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.close-modal:hover {
|
||
background-color: #eee;
|
||
color: #333;
|
||
}
|
||
|
||
.error {
|
||
color: #dc3545;
|
||
background-color: #f8d7da;
|
||
border: 1px solid #f5c6cb;
|
||
border-radius: 4px;
|
||
padding: 10px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.station-marker {
|
||
width: 24px;
|
||
height: 24px;
|
||
background-color: #007bff;
|
||
border: 2px solid white;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.station-marker.offline {
|
||
background-color: #6c757d;
|
||
}
|
||
|
||
.station-label {
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
border: 1px solid #007bff;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.controls {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.control-group {
|
||
width: 100%;
|
||
}
|
||
|
||
select, input {
|
||
width: 100%;
|
||
}
|
||
|
||
.map-container {
|
||
height: 50vh;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body x-data="{ showPastForecast: false, deviceModalOpen: false }" class="text-[14px] md:text-[15px]" x-init="window.addEventListener('close-device-modal', () => { deviceModalOpen = false })">
|
||
<div class="header p-2 text-center border-b border-gray-200">
|
||
<h1 class="text-2xl md:text-3xl font-semibold p-7">{{.Title}}</h1>
|
||
</div>
|
||
|
||
<div id="deviceModal" class="device-modal" x-show="deviceModalOpen" x-transition.opacity @click.self="deviceModalOpen=false">
|
||
<div class="device-modal-content bg-white shadow-xl" x-transition.scale.duration.150ms>
|
||
<div class="device-list-header flex items-center justify-between border-b">
|
||
<div class="text-sm">设备列表</div>
|
||
<span class="close-modal" @click="deviceModalOpen=false">×</span>
|
||
</div>
|
||
<div id="deviceList" class="device-list">
|
||
</div>
|
||
<div class="device-list-footer">
|
||
<div class="pagination">
|
||
<button class="pagination-btn" id="prevPage" :disabled="window.WeatherApp.currentPage <= 1" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage - 1)">< 上一页</button>
|
||
<span>第 <span id="currentPage">1</span> 页,共 <span id="totalPages">1</span> 页</span>
|
||
<button class="pagination-btn" id="nextPage" :disabled="window.WeatherApp.currentPage >= Math.ceil(window.WeatherApp.filteredDevices.length / window.WeatherApp.itemsPerPage)" @click="window.WeatherApp.updateDeviceList(window.WeatherApp.currentPage + 1)">下一页 ></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container content-narrow py-5">
|
||
<div class="system-info bg-gray-100 p-3 mb-5 rounded text-sm">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<strong>在线设备: </strong> <span id="onlineDevices">{{.OnlineDevices}}</span> 个 |
|
||
<strong>总设备: </strong>
|
||
<a href="#" id="showDeviceList" class="text-blue-600 hover:text-blue-700 underline-offset-2" @click.prevent="deviceModalOpen = true; window.WeatherApp.updateDeviceList(1)"><span id="wh65lpCount">0</span> 个</a>
|
||
</div>
|
||
<nav class="flex items-center gap-2">
|
||
<a id="tab-station" href="#station" class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white">气象站</a>
|
||
<a id="tab-radar" href="#radar" class="px-3 py-1 rounded text-sm text-blue-700 hover:bg-blue-50">南宁雷达</a>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-station">
|
||
<div class="controls flex flex-col gap-4 mb-5 p-4 border rounded bg-white">
|
||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||
<div class="station-input-group flex items-center gap-1">
|
||
<label for="stationInput" class="text-sm text-gray-600">站点编号:</label>
|
||
<input type="text" id="stationInput" placeholder="" class="w-32 px-2 py-1 border border-gray-300 rounded text-sm font-mono">
|
||
</div>
|
||
|
||
<div class="control-group">
|
||
<label for="mapType" class="text-sm text-gray-600">地图类型:</label>
|
||
<select id="mapType" onchange="switchLayer(this.value)" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||
<option value="satellite">卫星图</option>
|
||
<option value="vector">矢量图</option>
|
||
<option value="terrain">地形图</option>
|
||
<option value="hybrid">混合地形图</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="control-group">
|
||
<label for="forecastProvider" class="text-sm text-gray-600">预报源:</label>
|
||
<select id="forecastProvider" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||
<option value="">不显示预报</option>
|
||
<option value="open-meteo">欧洲气象局</option>
|
||
<option value="caiyun" selected>彩云</option>
|
||
<option value="imdroid">英卓</option>
|
||
<option value="cma">中央气象台</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="control-group">
|
||
<label for="legendMode" class="text-sm text-gray-600">图例展示:</label>
|
||
<select id="legendMode" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||
<option value="combo_standard" selected>综合</option>
|
||
<option value="verify_all">全部气象要素对比</option>
|
||
<option value="temp_compare">温度对比</option>
|
||
<option value="hum_compare">湿度对比</option>
|
||
<option value="rain_all">降水(+1/+2/+3h)</option>
|
||
<option value="pressure_compare">气压对比</option>
|
||
<option value="wind_compare">风速对比</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="control-row flex items-center gap-4 flex-wrap">
|
||
<div class="control-group flex items-center gap-1">
|
||
<label for="interval" class="text-sm text-gray-600">数据粒度:</label>
|
||
<select id="interval" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||
<option value="raw">原始(16s)</option>
|
||
<option value="10min">10分钟</option>
|
||
<option value="30min">30分钟</option>
|
||
<option value="1hour" selected>1小时</option>
|
||
|
||
</select>
|
||
</div>
|
||
|
||
<div class="control-group" id="timeRangeGroup">
|
||
<label for="startDate" class="text-sm text-gray-600">开始时间:</label>
|
||
<input type="datetime-local" id="startDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||
<label for="endDate" class="text-sm text-gray-600">结束时间:</label>
|
||
<input type="datetime-local" id="endDate" class="px-2 py-1 border border-gray-300 rounded text-sm">
|
||
<button onclick="queryHistoryData()" id="queryBtn" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-3 py-1 rounded">查看历史数据</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="map-container border border-gray-200 rounded" id="mapContainer">
|
||
<div id="map"></div>
|
||
<button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
|
||
</div>
|
||
|
||
<div class="chart-container" id="chartContainer">
|
||
<div id="stationInfoTitle" class="station-info-title"></div>
|
||
<div class="chart-wrapper">
|
||
<canvas id="combinedChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-container" id="tableContainer">
|
||
<div id="forecastToggleContainer" style="padding: 8px 12px;font-size: 12px;color: #666;display: none;display: flex;justify-content: flex-start;align-items: center;align-content: center;">
|
||
<label style="display: flex;align-items: center;gap: 5px;">
|
||
<input type="checkbox" id="showPastForecast" style="vertical-align: middle;" x-model="showPastForecast" @change="window.WeatherTable.display(window.WeatherApp.cachedHistoryData, window.WeatherApp.cachedForecastData)">
|
||
显示历史预报
|
||
</label>
|
||
</div>
|
||
<table class="min-w-full text-sm text-center">
|
||
<thead>
|
||
<tr id="tableHeader">
|
||
<th>时间</th>
|
||
<th>温度 (°C)</th>
|
||
<th>湿度 (%)</th>
|
||
<th>气压 (hPa)</th>
|
||
<th>风速 (m/s)</th>
|
||
<th>风向 (°)</th>
|
||
<th>雨量 (mm)</th>
|
||
<th>光照 (lux)</th>
|
||
<th>紫外线</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tableBody">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-radar" style="display: none;">
|
||
<div class="bg-white border border-gray-200 rounded p-4 text-gray-700 radar-grid">
|
||
<div id="radarInfo" class="text-sm mb-3">正在加载最新雷达数据...</div>
|
||
|
||
<div class="flex items-center gap-2 mb-3 text-sm">
|
||
<button id="radar-tab-china" class="px-3 py-1 rounded bg-blue-600 text-white">中国</button>
|
||
<button id="radar-tab-huanan" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">华南</button>
|
||
<button id="radar-tab-nanning" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">南宁</button>
|
||
<button id="radar-tab-cma" class="px-3 py-1 rounded bg-gray-100 text-blue-700 hover:bg-blue-50">CMA</button>
|
||
</div>
|
||
|
||
<div class="img-wrap">
|
||
<img id="radar-main-img" alt="radar" />
|
||
</div>
|
||
|
||
<div id="radar-heat-section" class="mt-4">
|
||
<div class="text-xs text-gray-500 mb-1">二维渲染(dBZ)</div>
|
||
<div class="w-full flex justify-center">
|
||
<div id="radar-heat-plot" style="width:75%;max-width:640px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.TIANDITU_KEY = '{{.TiandituKey}}';
|
||
</script>
|
||
<script>
|
||
(function() {
|
||
function getViewFromHash() {
|
||
return (location.hash || '#station').replace('#','');
|
||
}
|
||
function setActive(view) {
|
||
var stationView = document.getElementById('view-station');
|
||
var radarView = document.getElementById('view-radar');
|
||
var tabStation = document.getElementById('tab-station');
|
||
var tabRadar = document.getElementById('tab-radar');
|
||
|
||
var activeClasses = ['bg-blue-600', 'text-white', 'font-medium'];
|
||
var inactiveClasses = ['text-blue-700', 'hover:bg-blue-50'];
|
||
|
||
if (view === 'radar') {
|
||
stationView.style.display = 'none';
|
||
radarView.style.display = 'block';
|
||
tabStation.classList.remove.apply(tabStation.classList, activeClasses);
|
||
inactiveClasses.forEach(c=>tabStation.classList.add(c));
|
||
inactiveClasses.forEach(c=>tabRadar.classList.remove(c));
|
||
activeClasses.forEach(c=>tabRadar.classList.add(c));
|
||
} else {
|
||
stationView.style.display = 'block';
|
||
radarView.style.display = 'none';
|
||
tabRadar.classList.remove.apply(tabRadar.classList, activeClasses);
|
||
inactiveClasses.forEach(c=>tabRadar.classList.add(c));
|
||
inactiveClasses.forEach(c=>tabStation.classList.remove(c));
|
||
activeClasses.forEach(c=>tabStation.classList.add(c));
|
||
}
|
||
}
|
||
function initTabs() {
|
||
var view = getViewFromHash();
|
||
setActive(view);
|
||
if (view === 'radar') { loadRadarLatest(); loadPlotGrid(); }
|
||
}
|
||
window.addEventListener('hashchange', initTabs);
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
var ts = document.getElementById('tab-station');
|
||
var tr = document.getElementById('tab-radar');
|
||
if (ts) ts.addEventListener('click', function(e){ e.preventDefault(); location.hash = '#station';});
|
||
if (tr) tr.addEventListener('click', function(e){ e.preventDefault(); location.hash = '#radar';});
|
||
initTabs();
|
||
});
|
||
|
||
async function loadRadarLatest() {
|
||
var infoEl = document.getElementById('radarInfo');
|
||
try {
|
||
const res = await fetch('/api/radar/latest');
|
||
if (!res.ok) throw new Error('no data');
|
||
const data = await res.json();
|
||
const meta = data.meta || {};
|
||
const images = data.images || {};
|
||
infoEl.textContent = '时间: ' + (meta.timestamp_local || '未知');
|
||
window.RadarLatestImages = images;
|
||
setRadarImage('china');
|
||
bindRadarTabs();
|
||
} catch (e) {
|
||
infoEl.textContent = '暂无最新雷达数据';
|
||
}
|
||
}
|
||
|
||
function bindRadarTabs() {
|
||
var ids = ['china','huanan','nanning','cma'];
|
||
ids.forEach(function(k){
|
||
var el = document.getElementById('radar-tab-' + k);
|
||
if (el) el.onclick = function(){ setRadarImage(k); };
|
||
});
|
||
}
|
||
|
||
function setRadarImage(kind) {
|
||
var images = window.RadarLatestImages || {};
|
||
var url = images[kind];
|
||
var img = document.getElementById('radar-main-img');
|
||
if (url) { img.src = url + '?t=' + Date.now(); }
|
||
// toggle active styles
|
||
var ids = ['china','huanan','nanning','cma'];
|
||
ids.forEach(function(k){
|
||
var el = document.getElementById('radar-tab-' + k);
|
||
if (!el) return;
|
||
if (k === kind) {
|
||
el.classList.add('bg-blue-600','text-white');
|
||
el.classList.remove('bg-gray-100','text-blue-700');
|
||
} else {
|
||
el.classList.remove('bg-blue-600','text-white');
|
||
el.classList.add('bg-gray-100','text-blue-700');
|
||
}
|
||
});
|
||
}
|
||
|
||
async function loadPlotGrid(){
|
||
const res = await fetch('/api/radar/latest/grid');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
window.RadarLatestGrid = data;
|
||
renderPlotlyHeat(data);
|
||
renderClustersPanel();
|
||
renderWindQueryList();
|
||
renderWindResults();
|
||
}
|
||
|
||
function renderPlotlyHeat(payload){
|
||
// Preserve real dBZ values for z (so hover/scale show dBZ)
|
||
var z = (payload.grid || []).slice();
|
||
var colors = [
|
||
'#0000F6','#01A0F6','#00ECEC','#01FF00','#00C800',
|
||
'#019000','#FFFF00','#E7C000','#FF9000','#FF0000',
|
||
'#D60000','#C00000','#FF00F0','#780084','#AD90F0'
|
||
];
|
||
// Build step-like colorscale over dBZ domain [0, 5*(n-1)]
|
||
var zmin = 0;
|
||
var zmax = 5 * (colors.length - 1); // 70 for 15 colors
|
||
var colorscale = [];
|
||
for (var i=0;i<colors.length;i++){
|
||
var lo = (i*5)/zmax;
|
||
var hi = ((i+1)*5)/zmax;
|
||
if (i === colors.length-1) hi = 1.0;
|
||
// duplicate stops to create discrete bands
|
||
colorscale.push([lo, colors[i]]);
|
||
colorscale.push([hi, colors[i]]);
|
||
}
|
||
|
||
// Compute lon/lat arrays from bounds + res for hover/axes
|
||
var w = (payload.bounds && typeof payload.bounds.west === 'number') ? payload.bounds.west : 0;
|
||
var s = (payload.bounds && typeof payload.bounds.south === 'number') ? payload.bounds.south : 0;
|
||
var e = (payload.bounds && typeof payload.bounds.east === 'number') ? payload.bounds.east : 256;
|
||
var n = (payload.bounds && typeof payload.bounds.north === 'number') ? payload.bounds.north : 256;
|
||
var res = (typeof payload.res_deg === 'number' && payload.res_deg > 0) ? payload.res_deg : ((e - w) / 256.0);
|
||
// Use pixel centers for coordinates
|
||
var xs = new Array(256);
|
||
var ys = new Array(256);
|
||
for (var xi = 0; xi < 256; xi++) { xs[xi] = w + (xi + 0.5) * res; }
|
||
for (var yi = 0; yi < 256; yi++) { ys[yi] = s + (yi + 0.5) * res; }
|
||
|
||
// Build customdata to carry pixel (x,y) indices for hover
|
||
var cd = new Array(256);
|
||
for (var r = 0; r < 256; r++) {
|
||
var row = new Array(256);
|
||
for (var c = 0; c < 256; c++) {
|
||
row[c] = [c, r];
|
||
}
|
||
cd[r] = row;
|
||
}
|
||
|
||
var trace = {
|
||
z: z,
|
||
x: xs,
|
||
y: ys,
|
||
type: 'heatmap',
|
||
colorscale: colorscale,
|
||
colorbar: { title: 'dBZ', thickness: 18 },
|
||
zauto: false,
|
||
zmin: zmin,
|
||
zmax: zmax,
|
||
zsmooth: false,
|
||
customdata: cd,
|
||
hovertemplate: 'x=%{customdata[0]}, y=%{customdata[1]}<br>lon=%{x:.6f}, lat=%{y:.6f}<br>dBZ=%{z:.1f}<extra></extra>'
|
||
};
|
||
var box = document.getElementById('radar-heat-plot');
|
||
var size = Math.max(220, Math.min(520, Math.floor((box.clientWidth || 520))));
|
||
var cbx = 60; // approximate space for colorbar + padding
|
||
var layout = {
|
||
margin: {l:50, r:10, t:10, b:40},
|
||
xaxis: {
|
||
tickformat: '.3f',
|
||
range: [w, e]
|
||
},
|
||
yaxis: {
|
||
tickformat: '.3f',
|
||
range: [s, n]
|
||
},
|
||
width: size + cbx,
|
||
height: size,
|
||
};
|
||
var config = {displayModeBar:false, responsive:true};
|
||
Plotly.newPlot('radar-heat-plot', [trace], layout, config).then(function(){
|
||
window.addEventListener('resize', function(){
|
||
var s2 = Math.max(220, Math.min(520, Math.floor((box.clientWidth || 520))));
|
||
Plotly.relayout('radar-heat-plot', {width: s2 + cbx, height: s2});
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderClustersPanel(){
|
||
// fetch meta to read clusters
|
||
fetch('/api/radar/latest').then(r=>r.json()).then(function(resp){
|
||
var meta = resp.meta || {};
|
||
var clusters = meta.clusters || [];
|
||
var host = '/radar/latest/';
|
||
var containerId = 'radar-clusters';
|
||
var parent = document.getElementById(containerId);
|
||
if (!parent) {
|
||
var sec = document.createElement('div');
|
||
sec.id = containerId;
|
||
sec.className = 'mt-4';
|
||
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
||
root.appendChild(sec);
|
||
parent = sec;
|
||
}
|
||
if (!clusters.length) { parent.innerHTML = '<div class="text-sm text-gray-500">暂无 >=40 dBZ 云团</div>'; return; }
|
||
var html = '<div class="text-sm text-gray-700 mb-2">云团(dBZ≥40)共 ' + clusters.length + ' 个</div>';
|
||
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">';
|
||
clusters.forEach(function(cl){
|
||
var png = cl.png ? (host + cl.png) : '';
|
||
html += '<div class="border border-gray-200 rounded p-2">';
|
||
if (png) {
|
||
html += '<div class="mb-2 flex items-center justify-center" style="background:#fafafa">'
|
||
+ '<img src="'+png+'" style="image-rendering: pixelated; max-width: 100%; max-height: 120px;" />'
|
||
+ '</div>';
|
||
}
|
||
html += '<div class="text-xs text-gray-600">'
|
||
+ 'ID: '+cl.id+' | 像元: '+cl.area_px+'<br/>'
|
||
+ '质心: '+cl.lon.toFixed(4)+', '+cl.lat.toFixed(4)+'<br/>'
|
||
+ 'dBZ: max '+cl.max_dbz.toFixed(1)+' / avg '+cl.avg_dbz.toFixed(1)
|
||
+ '</div>';
|
||
if (cl.samples && cl.samples.length) {
|
||
html += '<div class="mt-1 text-xs text-gray-600">采样点: ' + cl.samples.map(function(s){
|
||
return s.role+':('+s.lon.toFixed(3)+','+s.lat.toFixed(3)+')';
|
||
}).join(' | ') + '</div>';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
parent.innerHTML = html;
|
||
}).catch(function(){ /* ignore */ });
|
||
}
|
||
|
||
function renderWindQueryList(){
|
||
fetch('/api/radar/latest').then(r=>r.json()).then(function(resp){
|
||
var meta = resp.meta || {};
|
||
var params = meta.query_params || {};
|
||
var cands = meta.query_candidates || [];
|
||
var containerId = 'radar-wind-query';
|
||
var parent = document.getElementById(containerId);
|
||
if (!parent) {
|
||
var sec = document.createElement('div');
|
||
sec.id = containerId;
|
||
sec.className = 'mt-4';
|
||
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
||
root.appendChild(sec);
|
||
parent = sec;
|
||
}
|
||
var html = '<div class="text-sm text-gray-700 mb-2">风场查询参数</div>';
|
||
html += '<div class="text-xs text-gray-600 mb-2">'
|
||
+ 'min_area_px='+ (params.min_area_px||9)
|
||
+ ',strong_dbz_override=' + (params.strong_dbz_override||50)
|
||
+ ',max_samples_per_cluster=' + (params.max_samples_per_cluster||5)
|
||
+ ',max_candidates_total=' + (params.max_candidates_total||25)
|
||
+ '</div>';
|
||
if (!cands.length) {
|
||
html += '<div class="text-xs text-gray-500">暂无需要查询的采样点</div>';
|
||
} else {
|
||
html += '<div class="text-sm text-gray-700 mb-1">需要查询的采样点(共 '+cands.length+' 个)</div>';
|
||
html += '<ul class="list-disc pl-5 text-xs text-gray-700">';
|
||
cands.forEach(function(p){
|
||
html += '<li>cluster='+p.cluster_id+' | '+p.role+' | lon='+p.lon.toFixed(4)+', lat='+p.lat.toFixed(4)+'</li>';
|
||
});
|
||
html += '</ul>';
|
||
}
|
||
parent.innerHTML = html;
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function renderWindResults(){
|
||
fetch('/api/radar/latest/wind').then(r=>r.json()).then(function(resp){
|
||
var station = resp.station || {};
|
||
var cands = resp.candidates || [];
|
||
var clusters = resp.clusters || [];
|
||
var containerId = 'radar-wind-results';
|
||
var parent = document.getElementById(containerId);
|
||
if (!parent) {
|
||
var sec = document.createElement('div');
|
||
sec.id = containerId;
|
||
sec.className = 'mt-4';
|
||
var root = document.getElementById('view-radar').querySelector('.radar-grid');
|
||
root.appendChild(sec);
|
||
parent = sec;
|
||
}
|
||
var html = '<div class="text-sm text-gray-700 mb-2">风场查询结果(彩云 10m 实况)</div>';
|
||
// cluster summary
|
||
if (clusters.length) {
|
||
html += '<div class="text-xs text-gray-700 mb-2">云团汇总:</div>';
|
||
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mb-3">';
|
||
clusters.forEach(function(cl){
|
||
html += '<div class="border border-gray-200 rounded p-2 text-xs text-gray-700">'
|
||
+ 'ID '+cl.cluster_id+' | 距离 '+(cl.distance_km||0).toFixed(1)+' km<br/>'
|
||
+ '风 '+(cl.speed_ms||0).toFixed(1)+' m/s, 去向 '+(cl.dir_to_deg||0).toFixed(0)+'°<br/>'
|
||
+ (cl.coming?('<span class="text-green-700">朝向</span>, ETA '+(cl.eta_min||0).toFixed(1)+' 分钟'):'<span class="text-gray-500">非朝向</span>')
|
||
+ '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
// candidate details
|
||
if (cands.length) {
|
||
html += '<div class="text-xs text-gray-700 mb-2">采样点明细:</div>';
|
||
html += '<div class="overflow-x-auto"><table class="min-w-full text-xs text-gray-700"><thead><tr>'
|
||
+ '<th class="px-2 py-1 border">cluster</th>'
|
||
+ '<th class="px-2 py-1 border">role</th>'
|
||
+ '<th class="px-2 py-1 border">lon</th>'
|
||
+ '<th class="px-2 py-1 border">lat</th>'
|
||
+ '<th class="px-2 py-1 border">spd(m/s)</th>'
|
||
+ '<th class="px-2 py-1 border">dir_from(°)</th>'
|
||
+ '<th class="px-2 py-1 border">T(°C)</th>'
|
||
+ '<th class="px-2 py-1 border">RH</th>'
|
||
+ '<th class="px-2 py-1 border">P(hPa)</th>'
|
||
+ '<th class="px-2 py-1 border">err</th>'
|
||
+ '</tr></thead><tbody>';
|
||
cands.forEach(function(p){
|
||
var w = p.wind || {};
|
||
html += '<tr>'
|
||
+ '<td class="px-2 py-1 border">'+p.cluster_id+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+p.role+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+p.lon.toFixed(4)+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+p.lat.toFixed(4)+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+(w.speed_ms!=null?w.speed_ms.toFixed(1):'')+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+(w.dir_from_deg!=null?w.dir_from_deg.toFixed(0):'')+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+(w.temp_c!=null?w.temp_c.toFixed(1):'')+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+(w.rh!=null?(w.rh*100).toFixed(0)+'%':'')+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+(w.pressure_hpa!=null?w.pressure_hpa.toFixed(1):'')+'</td>'
|
||
+ '<td class="px-2 py-1 border">'+(p.error||'')+'</td>'
|
||
+ '</tr>';
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
parent.innerHTML = html;
|
||
}).catch(function(){});
|
||
}
|
||
})();
|
||
</script>
|
||
<script defer src="/static/js/alpinejs.min.js"></script>
|
||
<script src="/static/js/utils.js"></script>
|
||
<script src="/static/js/weather-app.js"></script>
|
||
<script src="/static/js/weather-chart.js"></script>
|
||
<script src="/static/js/weather-table.js"></script>
|
||
<script src="/static/js/app.js"></script>
|
||
</body>
|
||
</html>
|