[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>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #1a1f2e;
--fg: #f0f4f8;
--muted: #8b9cb3;
--accent: #ff6b35;
--accent-glow: rgba(255, 107, 53, 0.4);
--card: rgba(26, 31, 46, 0.95);
--card-border: rgba(255, 255, 255, 0.1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans SC', 'Space Grotesk', sans-serif;
background: var(--bg);
color: var(--fg);
height: 100vh;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
.leaflet-container {
background: #1e2636;
font-family: inherit;
}
.leaflet-control-zoom {
border: none !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.3) !important;
}
.leaflet-control-zoom a {
background: var(--card) !important;
color: var(--fg) !important;
border: 1px solid var(--card-border) !important;
width: 36px !important;
height: 36px !important;
line-height: 36px !important;
font-size: 18px !important;
}
.leaflet-control-zoom a:hover {
background: rgba(255, 107, 53, 0.2) !important;
color: var(--accent) !important;
}
.custom-marker {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #ff8c5a);
border: 3px solid #fff;
box-shadow: 0 4px 24px var(--accent-glow);
display: flex;
align-items: center;
justify-content: center;
animation: markerPulse 2s ease-in-out infinite;
}
@keyframes markerPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
.weather-panel {
position: fixed;
top: 20px;
right: 20px;
width: 400px;
max-height: calc(100vh - 40px);
background: var(--card);
backdrop-filter: blur(24px);
border: 1px solid var(--card-border);
border-radius: 24px;
padding: 28px;
z-index: 1000;
transform: translateX(460px);
opacity: 0;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.weather-panel.active {
transform: translateX(0);
opacity: 1;
}
/* 自定义滚动条 */
.weather-panel::-webkit-scrollbar {
width: 4px;
}
.weather-panel::-webkit-scrollbar-track {
background: transparent;
}
.weather-panel::-webkit-scrollbar-thumb {
background: var(--accent);
border-radius: 10px;
}
.close-btn {
position: sticky;
top: 0;
float: right;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
border: none;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
margin-bottom: 8px;
}
.close-btn:hover {
background: rgba(255, 107, 53, 0.2);
color: var(--accent);
transform: rotate(90deg);
}
.location-name {
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 4px;
letter-spacing: -0.02em;
line-height: 1.2;
}
.location-coords {
font-size: 0.85rem;
color: var(--muted);
font-family: 'Space Grotesk', monospace;
}
.elevation-info {
font-size: 0.8rem;
color: var(--muted);
opacity: 0.7;
margin-top: 2px;
}
/* 当前天气 */
.current-weather {
margin-top: 20px;
padding: 24px;
background: linear-gradient(135deg, rgba(255, 107, 53, 0.15), rgba(255, 107, 53, 0.05));
border-radius: 20px;
display: flex;
align-items: center;
gap: 20px;
border: 1px solid rgba(255, 107, 53, 0.1);
}
.weather-icon {
width: 88px;
height: 88px;
flex-shrink: 0;
}
.weather-icon svg {
width: 100%;
height: 100%;
}
.temp-current {
font-size: 3.5rem;
font-weight: 700;
font-family: 'Space Grotesk', sans-serif;
line-height: 1;
}
.weather-desc {
font-size: 1.05rem;
color: var(--muted);
margin-top: 4px;
}
.feels-like {
font-size: 0.85rem;
color: var(--muted);
opacity: 0.7;
margin-top: 2px;
}
/* 所有详情网格 */
.weather-details {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 20px;
}
.detail-item {
padding: 14px 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 14px;
border: 1px solid var(--card-border);
transition: all 0.2s ease;
text-align: center;
}
.detail-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 107, 53, 0.2);
}
.detail-label {
font-size: 0.65rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.detail-value {
font-size: 1.1rem;
font-weight: 600;
margin-top: 4px;
font-family: 'Space Grotesk', sans-serif;
}
.detail-value .unit {
font-size: 0.7rem;
font-weight: 400;
color: var(--muted);
margin-left: 2px;
}
/* 日出日落 */
.sun-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.sun-item {
padding: 14px;
background: rgba(255, 255, 255, 0.03);
border-radius: 14px;
border: 1px solid var(--card-border);
text-align: center;
}
.sun-item .label {
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sun-item .value {
font-size: 1.1rem;
font-weight: 600;
font-family: 'Space Grotesk', monospace;
margin-top: 4px;
}
.sun-item .value.sunrise { color: #FFB347; }
.sun-item .value.sunset { color: #FF6B6B; }
.forecast-title {
margin-top: 24px;
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.forecast-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.forecast-item {
display: flex;
align-items: center;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: 14px;
border: 1px solid var(--card-border);
transition: all 0.2s ease;
}
.forecast-item:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 107, 53, 0.25);
transform: translateX(4px);
}
.forecast-day {
width: 60px;
font-weight: 600;
font-size: 0.9rem;
}
.forecast-icon {
width: 36px;
height: 36px;
margin: 0 12px;
flex-shrink: 0;
}
.forecast-icon svg {
width: 100%;
height: 100%;
}
.forecast-desc {
flex: 1;
font-size: 0.85rem;
color: var(--muted);
}
.forecast-temps {
display: flex;
gap: 10px;
font-family: 'Space Grotesk', sans-serif;
}
.temp-high {
font-weight: 700;
font-size: 1rem;
}
.temp-low {
color: var(--muted);
font-size: 1rem;
}
.forecast-extra {
font-size: 0.7rem;
color: var(--muted);
opacity: 0.6;
margin-top: 2px;
}
/* 小时预报 */
.hourly-title {
margin-top: 20px;
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.hourly-scroll {
display: flex;
gap: 10px;
overflow-x: auto;
margin-top: 12px;
padding-bottom: 8px;
}
.hourly-scroll::-webkit-scrollbar {
height: 3px;
}
.hourly-scroll::-webkit-scrollbar-thumb {
background: var(--accent);
border-radius: 10px;
}
.hourly-item {
flex: 0 0 72px;
text-align: center;
padding: 12px 6px;
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
border: 1px solid var(--card-border);
transition: all 0.2s ease;
}
.hourly-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 107, 53, 0.2);
}
.hourly-item .time {
font-size: 0.7rem;
color: var(--muted);
font-family: 'Space Grotesk', monospace;
}
.hourly-item .h-icon {
width: 28px;
height: 28px;
margin: 4px auto;
}
.hourly-item .h-icon svg {
width: 100%;
height: 100%;
}
.hourly-item .h-temp {
font-size: 0.95rem;
font-weight: 600;
font-family: 'Space Grotesk', sans-serif;
}
.hourly-item .h-precip {
font-size: 0.6rem;
color: #4A90D9;
margin-top: 2px;
}
.hint-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 999;
pointer-events: none;
opacity: 1;
transition: opacity 0.4s ease;
}
.hint-overlay.hidden {
opacity: 0;
}
.hint-icon {
width: 72px;
height: 72px;
margin: 0 auto 20px;
opacity: 0.5;
animation: hintBounce 2.5s ease-in-out infinite;
}
@keyframes hintBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.hint-text {
font-size: 1.05rem;
color: var(--fg);
background: var(--card);
padding: 16px 32px;
border-radius: 100px;
border: 1px solid var(--card-border);
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
}
.loading-spinner {
width: 44px;
height: 44px;
border: 3px solid rgba(255, 107, 53, 0.15);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 48px auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-msg {
text-align: center;
padding: 48px 20px;
color: var(--muted);
}
.data-source {
margin-top: 16px;
font-size: 0.6rem;
color: var(--muted);
opacity: 0.4;
text-align: center;
border-top: 1px solid var(--card-border);
padding-top: 14px;
}
[url=home.php?mod=space&uid=945662]@media[/url] (max-width: 640px) {
.weather-panel {
width: calc(100% - 16px);
right: 8px;
top: auto;
bottom: 8px;
max-height: 60vh;
padding: 20px;
transform: translateY(100%);
border-radius: 20px;
}
.weather-panel.active {
transform: translateY(0);
}
.temp-current {
font-size: 2.8rem;
}
.weather-icon {
width: 70px;
height: 70px;
}
.weather-details {
grid-template-columns: repeat(2, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<div id="map"></div>
<div class="hint-overlay" id="hint">
<div class="hint-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</div>
<div class="hint-text">点击地图查看完整天气</div>
</div>
<div class="weather-panel" id="weatherPanel">
<button class="close-btn" id="closeBtn" aria-label="关闭面板">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<div id="panelContent">
<div class="loading-spinner"></div>
</div>
</div>
<script>
// ============================================================
// 1. 初始化地图
// ============================================================
const map = L.map('map', {
center: [35, 105],
zoom: 5,
zoomControl: false,
attributionControl: false
});
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
subdomains: ['1', '2', '3', '4'],
maxZoom: 18
}).addTo(map);
L.control.zoom({ position: 'bottomleft' }).addTo(map);
let currentMarker = null;
const weatherPanel = document.getElementById('weatherPanel');
const panelContent = document.getElementById('panelContent');
const closeBtn = document.getElementById('closeBtn');
const hint = document.getElementById('hint');
// ============================================================
// 2. 天气代码映射 (WMO 标准)
// ============================================================
const weatherCodes = {
0: { desc: '晴天', icon: 'clear' },
1: { desc: '基本晴朗', icon: 'partly-cloudy' },
2: { desc: '局部多云', icon: 'partly-cloudy' },
3: { desc: '阴天', icon: 'cloudy' },
45: { desc: '雾', icon: 'fog' },
48: { desc: '霜雾', icon: 'fog' },
51: { desc: '小毛毛雨', icon: 'drizzle' },
53: { desc: '毛毛雨', icon: 'drizzle' },
55: { desc: '大毛毛雨', icon: 'drizzle' },
56: { desc: '冻毛毛雨', icon: 'sleet' },
57: { desc: '大冻毛毛雨', icon: 'sleet' },
61: { desc: '小雨', icon: 'rain' },
63: { desc: '中雨', icon: 'rain' },
65: { desc: '大雨', icon: 'rain' },
66: { desc: '冻雨', icon: 'sleet' },
67: { desc: '大冻雨', icon: 'sleet' },
71: { desc: '小雪', icon: 'snow' },
73: { desc: '中雪', icon: 'snow' },
75: { desc: '大雪', icon: 'snow' },
77: { desc: '雪粒', icon: 'snow' },
80: { desc: '小阵雨', icon: 'showers' },
81: { desc: '阵雨', icon: 'showers' },
82: { desc: '大阵雨', icon: 'showers' },
85: { desc: '小阵雪', icon: 'snow' },
86: { desc: '大阵雪', icon: 'snow' },
95: { desc: '雷暴', icon: 'thunderstorm' },
96: { desc: '雷暴伴小冰雹', icon: 'thunderstorm' },
99: { desc: '雷暴伴大冰雹', icon: 'thunderstorm' }
};
// ============================================================
// 3. SVG 天气图标生成器
// ============================================================
function getWeatherIcon(type, size = 64) {
const icons = {
'clear': `<svg viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="12" fill="#FFD93D"/>
<g stroke="#FFD93D" stroke-width="2.5" stroke-linecap="round" opacity="0.8">
<line x1="32" y1="8" x2="32" y2="14"/><line x1="32" y1="50" x2="32" y2="56"/>
<line x1="8" y1="32" x2="14" y2="32"/><line x1="50" y1="32" x2="56" y2="32"/>
<line x1="15.3" y1="15.3" x2="19.5" y2="19.5"/><line x1="44.5" y1="44.5" x2="48.7" y2="48.7"/>
<line x1="15.3" y1="48.7" x2="19.5" y2="44.5"/><line x1="44.5" y1="19.5" x2="48.7" y2="15.3"/>
</g>
</svg>`,
'partly-cloudy': `<svg viewBox="0 0 64 64" fill="none">
<circle cx="22" cy="22" r="9" fill="#FFD93D"/>
<path d="M46 48H20a11 11 0 1 1 1-22h1a14 14 0 0 1 27 5c0 4-1 8-4 11h1z" fill="#E8EDF5"/>
</svg>`,
'cloudy': `<svg viewBox="0 0 64 64" fill="none">
<path d="M48 44H18a13 13 0 1 1 2-26 16 16 0 0 1 32 4c0 5-2 10-5 14h1z" fill="#D1D9E3"/>
</svg>`,
'fog': `<svg viewBox="0 0 64 64" fill="none">
<g stroke="#B8C4D4" stroke-width="4" stroke-linecap="round"><line x1="10" y1="26" x2="54" y2="26"/><line x1="8" y1="38" x2="56" y2="38"/><line x1="10" y1="50" x2="54" y2="50"/></g>
</svg>`,
'drizzle': `<svg viewBox="0 0 64 64" fill="none">
<path d="M46 32H20a11 11 0 1 1 1-22 14 14 0 0 1 27 5c0 4-1 7-3 10h1z" fill="#D1D9E3"/>
<g stroke="#6BB3F0" stroke-width="2" stroke-linecap="round">
<line x1="20" y1="42" x2="18" y2="50"/><line x1="32" y1="42" x2="30" y2="50"/><line x1="44" y1="42" x2="42" y2="50"/>
</g>
</svg>`,
'rain': `<svg viewBox="0 0 64 64" fill="none">
<path d="M46 30H20a11 11 0 1 1 1-22 14 14 0 0 1 27 5c0 4-1 7-3 10h1z" fill="#B8C4D4"/>
<g stroke="#4A90D9" stroke-width="3" stroke-linecap="round">
<line x1="18" y1="40" x2="14" y2="56"/><line x1="32" y1="40" x2="28" y2="56"/><line x1="46" y1="40" x2="42" y2="56"/>
</g>
</svg>`,
'sleet': `<svg viewBox="0 0 64 64" fill="none">
<path d="M46 28H20a11 11 0 1 1 1-22 14 14 0 0 1 27 5c0 4-1 7-3 10h1z" fill="#B8C4D4"/>
<g stroke="#6BB3F0" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="38" x2="16" y2="46"/><line x1="46" y1="38" x2="44" y2="46"/>
</g>
<g fill="#A8C8E8"><circle cx="28" cy="50" r="3"/><circle cx="40" cy="54" r="3"/></g>
</svg>`,
'snow': `<svg viewBox="0 0 64 64" fill="none">
<path d="M46 28H20a11 11 0 1 1 1-22 14 14 0 0 1 27 5c0 4-1 7-3 10h1z" fill="#D1D9E3"/>
<g fill="#A8C8E8">
<circle cx="18" cy="44" r="4"/><circle cx="32" cy="50" r="4"/><circle cx="46" cy="44" r="4"/>
<circle cx="25" cy="56" r="3"/><circle cx="39" cy="56" r="3"/>
</g>
</svg>`,
'showers': `<svg viewBox="0 0 64 64" fill="none">
<path d="M46 26H20a11 11 0 1 1 1-22 14 14 0 0 1 27 5c0 4-1 7-3 10h1z" fill="#98A8B8"/>
<g stroke="#4A90D9" stroke-width="4" stroke-linecap="round">
<line x1="16" y1="38" x2="10" y2="58"/><line x1="32" y1="38" x2="26" y2="58"/><line x1="48" y1="38" x2="42" y2="58"/>
</g>
</svg>`,
'thunderstorm': `<svg viewBox="0 0 64 64" fill="none">
<path d="M46 24H20a11 11 0 1 1 1-22 14 14 0 0 1 27 5c0 4-1 7-3 10h1z" fill="#6B7A8C"/>
<polygon points="36,32 28,44 34,44 30,58 44,40 38,40 42,32" fill="#FFD93D"/>
</svg>`
};
return icons[type] || icons['partly-cloudy'];
}
// ============================================================
// 4. 获取完整天气数据 (包含所有可用参数)
// ============================================================
async function getFullWeather(lat, lon) {
// 包含 Open-Meteo 所有可用参数
const params = {
latitude: lat,
longitude: lon,
// 当前数据
current: [
'temperature_2m',
'relative_humidity_2m',
'apparent_temperature',
'is_day',
'precipitation',
'rain',
'snowfall',
'weather_code',
'cloud_cover',
'wind_speed_10m',
'wind_direction_10m',
'wind_gusts_10m',
'pressure_msl',
'surface_pressure',
'visibility'
].join(','),
// 每日数据
daily: [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'apparent_temperature_max',
'apparent_temperature_min',
'sunrise',
'sunset',
'daylight_duration',
'sunshine_duration',
'uv_index_max',
'precipitation_sum',
'rain_sum',
'snowfall_sum',
'precipitation_hours',
'precipitation_probability_max',
'wind_speed_10m_max',
'wind_gusts_10m_max',
'wind_direction_10m_dominant'
].join(','),
// 逐小时数据
hourly: [
'temperature_2m',
'relative_humidity_2m',
'apparent_temperature',
'precipitation_probability',
'precipitation',
'rain',
'snowfall',
'weather_code',
'cloud_cover',
'visibility',
'wind_speed_10m',
'wind_direction_10m',
'wind_gusts_10m',
'uv_index'
].join(','),
timezone: 'auto',
forecast_days: '4'
};
const url = `https://api.open-meteo.com/v1/forecast?` +
Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&');
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// ============================================================
// 5. 工具函数
// ============================================================
function formatDate(dateStr, index) {
if (index === 0) return '今天';
if (index === 1) return '明天';
const date = new Date(dateStr);
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
return `${date.getMonth()+1}/${date.getDate()} 周${weekdays[date.getDay()]}`;
}
function formatTime(dateStr) {
const d = new Date(dateStr);
return d.toTimeString().slice(0, 5);
}
function formatHour(dateStr) {
return new Date(dateStr).toTimeString().slice(0, 5);
}
function getDayName(dateStr) {
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return weekdays[new Date(dateStr).getDay()];
}
// ============================================================
// 6. 渲染完整天气面板
// ============================================================
async function renderWeatherPanel(lat, lon) {
panelContent.innerHTML = '<div class="loading-spinner"></div>';
weatherPanel.classList.add('active');
hint.classList.add('hidden');
try {
const data = await getFullWeather(lat, lon);
const current = data.current;
const daily = data.daily;
const hourly = data.hourly;
// 当前天气
const currentWeather = weatherCodes[current.weather_code] || { desc: '未知', icon: 'partly-cloudy' };
const isDay = current.is_day === 1;
// ---- 构建三日预报 ----
let forecastHTML = '';
for (let i = 0; i < Math.min(3, daily.time.length); i++) {
const dayWeather = weatherCodes[daily.weather_code[i]] || { desc: '未知', icon: 'partly-cloudy' };
const precipProb = daily.precipitation_probability_max ? Math.round(daily.precipitation_probability_max[i]) : null;
const uvMax = daily.uv_index_max ? daily.uv_index_max[i].toFixed(1) : null;
forecastHTML += `
<div class="forecast-item">
<div class="forecast-day">${formatDate(daily.time[i], i)}</div>
<div class="forecast-icon">${getWeatherIcon(dayWeather.icon)}</div>
<div style="flex:1">
<div class="forecast-desc">${dayWeather.desc}</div>
<div class="forecast-extra">
${precipProb !== null ? `降水 ${precipProb}%` : ''}
${uvMax !== null ? ` · UV ${uvMax}` : ''}
</div>
</div>
<div class="forecast-temps">
<span class="temp-high">${Math.round(daily.temperature_2m_max[i])}°</span>
<span class="temp-low">${Math.round(daily.temperature_2m_min[i])}°</span>
</div>
</div>
`;
}
// ---- 构建逐小时预报 (未来12小时) ----
let hourlyHTML = '';
const now = new Date();
const hourStart = new Date(now);
hourStart.setMinutes(0, 0, 0);
let hourlyCount = 0;
for (let i = 0; i < hourly.time.length && hourlyCount < 12; i++) {
const hTime = new Date(hourly.time[i]);
if (hTime >= now) {
const hWeather = weatherCodes[hourly.weather_code[i]] || { desc: '未知', icon: 'partly-cloudy' };
const precip = hourly.precipitation ? hourly.precipitation[i] : 0;
hourlyHTML += `
<div class="hourly-item">
<div class="time">${formatHour(hourly.time[i])}</div>
<div class="h-icon">${getWeatherIcon(hWeather.icon)}</div>
<div class="h-temp">${Math.round(hourly.temperature_2m[i])}°</div>
${precip > 0 ? `<div class="h-precip">${precip.toFixed(1)}mm</div>` : ''}
</div>
`;
hourlyCount++;
}
}
// 如果没有未来小时数据(可能已过午夜),取前12个
if (!hourlyHTML) {
for (let i = 0; i < Math.min(12, hourly.time.length); i++) {
const hWeather = weatherCodes[hourly.weather_code[i]] || { desc: '未知', icon: 'partly-cloudy' };
const precip = hourly.precipitation ? hourly.precipitation[i] : 0;
hourlyHTML += `
<div class="hourly-item">
<div class="time">${formatHour(hourly.time[i])}</div>
<div class="h-icon">${getWeatherIcon(hWeather.icon)}</div>
<div class="h-temp">${Math.round(hourly.temperature_2m[i])}°</div>
${precip > 0 ? `<div class="h-precip">${precip.toFixed(1)}mm</div>` : ''}
</div>
`;
}
}
// ---- 组装主面板 ----
// 新增:通过高德逆地理编码获取当前经纬度对应的地点名称
let locationName = '定位中...';
try {
// ⚠️ 请将下方 YOUR_AMAP_KEY 替换为你自己申请的高德Web服务Key(免费额度足够个人使用)
const amapKey = 'YOUR_AMAP_KEY';
const geoRes = await fetch(`https://restapi.amap.com/v3/geocode/regeo?location=${lon},${lat}&key=${amapKey}&extensions=base`);
const geoData = await geoRes.json();
if (geoData.status === '1' && geoData.regeocode) {
// 优先取格式化地址中的商圈/乡镇/街道,兜底取完整格式化地址
locationName = geoData.regeocode.addressComponent?.township || geoData.regeocode.addressComponent?.streetNumber?.street || geoData.regeocode.formatted_address || '未知地点';
} else {
locationName = '未知地点';
}
} catch (e) {
console.warn('逆地理编码失败', e);
locationName = '未知地点';
}
panelContent.innerHTML = `
<!-- 位置信息 -->
<div style="display:flex;justify-content:space-between;align-items:start;">
<div>
<div class="location-name">${lat.toFixed(3)}°N, ${lon.toFixed(3)}°E</div>
<div class="location-coords">${locationName}</div>
${data.elevation ? `<div class="elevation-info">海拔 ${data.elevation}m</div>` : ''}
</div>
<div style="text-align:right;font-size:0.75rem;color:var(--muted);opacity:0.6;">
${isDay ? '☀️ 白天' : '🌙 夜间'}
</div>
</div>
<!-- 当前天气 -->
<div class="current-weather">
<div class="weather-icon">${getWeatherIcon(currentWeather.icon)}</div>
<div>
<div class="temp-current">${Math.round(current.temperature_2m)}°</div>
<div class="weather-desc">${currentWeather.desc}</div>
<div class="feels-like">体感 ${Math.round(current.apparent_temperature)}°</div>
</div>
</div>
<!-- 天气详情 (3列网格) -->
<div class="weather-details">
<div class="detail-item">
<div class="detail-label">湿度</div>
<div class="detail-value">${current.relative_humidity_2m}%</div>
</div>
<div class="detail-item">
<div class="detail-label">风速</div>
<div class="detail-value">${Math.round(current.wind_speed_10m)} <span class="unit">km/h</span></div>
</div>
<div class="detail-item">
<div class="detail-label">阵风</div>
<div class="detail-value">${Math.round(current.wind_gusts_10m || 0)} <span class="unit">km/h</span></div>
</div>
<div class="detail-item">
<div class="detail-label">风向</div>
<div class="detail-value">${current.wind_direction_10m !== undefined ? Math.round(current.wind_direction_10m) + '°' : '--'}</div>
</div>
<div class="detail-item">
<div class="detail-label">云量</div>
<div class="detail-value">${current.cloud_cover !== undefined ? Math.round(current.cloud_cover) + '%' : '--'}</div>
</div>
<div class="detail-item">
<div class="detail-label">气压</div>
<div class="detail-value">${current.pressure_msl !== undefined ? Math.round(current.pressure_msl) : '--'} <span class="unit">hPa</span></div>
</div>
<div class="detail-item">
<div class="detail-label">降水</div>
<div class="detail-value">${current.precipitation !== undefined ? current.precipitation.toFixed(1) : '0.0'} <span class="unit">mm</span></div>
</div>
<div class="detail-item">
<div class="detail-label">能见度</div>
<div class="detail-value">${current.visibility !== undefined ? Math.round(current.visibility/1000) : '--'} <span class="unit">km</span></div>
</div>
<div class="detail-item">
<div class="detail-label">紫外线</div>
<div class="detail-value">${current.uv_index !== undefined ? current.uv_index.toFixed(1) : '--'}</div>
</div>
</div>
<!-- 日出日落 -->
${daily.sunrise && daily.sunset ? `
<div class="sun-section">
<div class="sun-item">
<div class="label">🌅 日出</div>
<div class="value sunrise">${formatTime(daily.sunrise[0])}</div>
</div>
<div class="sun-item">
<div class="label">🌇 日落</div>
<div class="value sunset">${formatTime(daily.sunset[0])}</div>
</div>
</div>
` : ''}
<!-- 三日预报 -->
<div class="forecast-title">📅 三日预报</div>
<div class="forecast-list">${forecastHTML}</div>
<!-- 逐小时预报 -->
<div class="hourly-title">⏰ 逐小时预报</div>
<div class="hourly-scroll">${hourlyHTML}</div>
<!-- 数据来源 -->
<div class="data-source">
数据来源: Open-Meteo.com · 免费开源天气 API
</div>
`;
} catch (error) {
console.error('获取天气失败:', error);
panelContent.innerHTML = `
<div class="error-msg">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin: 0 auto 16px; opacity: 0.4;">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
<p>获取天气失败</p>
<p style="font-size:0.8rem;margin-top:8px;opacity:0.6;">${error.message}</p>
</div>
`;
}
}
// ============================================================
// 7. 地图交互
// ============================================================
function createMarker(lat, lon) {
if (currentMarker) map.removeLayer(currentMarker);
const markerIcon = L.divIcon({
className: 'custom-marker-wrapper',
html: '<div class="custom-marker"><svg width="18" height="18" viewBox="0 0 24 24" fill="white"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/></svg></div>',
iconSize: [36, 36],
iconAnchor: [18, 18]
});
currentMarker = L.marker([lat, lon], { icon: markerIcon }).addTo(map);
}
map.on('click', function(e) {
const lat = e.latlng.lat;
const lon = e.latlng.lng;
createMarker(lat, lon);
renderWeatherPanel(lat, lon);
});
// ============================================================
// 8. 关闭面板
// ============================================================
closeBtn.addEventListener('click', function() {
weatherPanel.classList.remove('active');
hint.classList.remove('hidden');
if (currentMarker) {
map.removeLayer(currentMarker);
currentMarker = null;
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && weatherPanel.classList.contains('active')) {
closeBtn.click();
}
});
</script>
</body>
</html>