Skip to content

Commit 29753c2

Browse files
committed
feat(briefing): 航路气象剖面
导入 SimBrief 计划后,简报页画出沿航线的高空风、顺风分量与温度, 取的是巡航高度上的插值。 只画巡航那一层,不铺开整个二维剖面:真正影响航程的就是这一层, 八层全画会得到一张谁也读不出结论的热力图。想看某点的完整廓线, 鼠标悬上去 tooltip 会按高度列出各层。 顺风分量单画一条并带零线 —— 三条线里只有它能直接回答「这趟省不省油」。 算它有两个容易搞反的地方,都写了测试钉住: - 气象上的风向是「风从哪来」,航向是「往哪去」,两者差 180° 才是顺风。 符号反了会让整段航程的油耗估算朝相反方向偏。 - 航向用大圆初始航向,不用经纬度差的平面近似 —— 高纬度上经度差会被 严重放大,欧亚航线能偏出几十度。 风向插值走最短弧:350° 与 010° 之间是 20°,直接平均会得到 180°, 正好指反,顺风会被读成顶风。 缺数据一律留空洞让折线断开,不补零:图上的「0 kt」看不出是没有数据。 20 例单测,7 处变异全部捕获。SimBrief 演示账号 KLAS→KLGB 全流程实测通过。
1 parent e15d296 commit 29753c2

12 files changed

Lines changed: 871 additions & 2 deletions

docs/CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,34 @@
99
1010
---
1111

12+
## 未发布
13+
14+
### 新增
15+
16+
- **航路气象剖面**:导入 SimBrief 计划后,简报页画出沿航线的高空风、顺风分量与温度。
17+
后端新增 `/api/v1/weather/route-profile`,把航路抽稀到 12 个点(长航线的 OFP 有上百个
18+
航路点,全发过去 URL 会爆也会撞上游限流),一次向 Open-Meteo 批量取 8 个气压层
19+
(850–150 hPa,覆盖 FL050–FL470)。每个采样点按**自己的 ETA** 取对应小时 ——
20+
长航线首尾能差十几个小时,一律用起飞时刻会让落地端的风完全对不上。
21+
高度用上游实测的位势高度而非气压层的标准高度:气压面会被高低压顶起压低,
22+
那正是要看的东西,用固定换算表会朝着天气系统的方向错放几百英尺。
23+
顺风分量单画一条并带零线 —— 三条线里只有它能直接回答「这趟省不省油」。
24+
后端 17 例单测 / 8 处变异全捕获,前端 20 例 / 7 处变异全捕获。
25+
26+
### 修复
27+
28+
-
29+
30+
### 调整
31+
32+
-
33+
34+
### 删除
35+
36+
-
37+
38+
---
39+
1240
## v1.0.6-beta — 2026-08-09
1341

1442
### 新增

docs/TODO.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,11 @@
103103

104104
图论部分不难,难点在 OSM 数据的连通性清洗(断头、重复节点、缺失连接)。
105105

106-
### 7. 航路气象剖面
106+
### 7. ~~航路气象剖面~~(已完成)
107107

108-
依赖 #4。Open-Meteo 已接入,有了航路即可沿航线画高空风/温度剖面。
108+
**✅ 已完成(2026-08-09)**:后端 `/api/v1/weather/route-profile` 沿航路抽稀取样,向 Open-Meteo 批量取 8 个气压层(850–150 hPa,覆盖 FL050–FL470)的风与温度;前端在简报页画出巡航高度上的风速 / 顺风分量 / 温度三条线,悬停看该点完整廓线。
109+
110+
**注意**:原有的 `/weather/wind/profile` 只到 180 米,是近地面层,够不着巡航高度,两者不是一回事,别混用。
109111

110112
---
111113

src/modules/briefing/localization/briefing-localization.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ export const BriefingLocalizationKeys = {
2626
simbriefSuccess: 'briefing.simbrief.success',
2727
simbriefFailed: 'briefing.simbrief.failed',
2828
simbriefNeedIdentity: 'briefing.simbrief.need_identity',
29+
profileTitle: 'briefing.profile.title',
30+
profileSubtitle: 'briefing.profile.subtitle',
31+
profileWind: 'briefing.profile.wind',
32+
profileTailwind: 'briefing.profile.tailwind',
33+
profileTemperature: 'briefing.profile.temperature',
34+
profileLoading: 'briefing.profile.loading',
35+
profileNeedPlan: 'briefing.profile.need_plan',
36+
profileFailed: 'briefing.profile.failed',
37+
profileRefresh: 'briefing.profile.refresh',
38+
profileCruise: 'briefing.profile.cruise',
2939
fieldArrival: 'briefing.field.arrival',
3040
fieldAlternate: 'briefing.field.alternate',
3141
fieldDepartureRunway: 'briefing.field.departure_runway',
@@ -94,6 +104,16 @@ export const briefingTranslations: ModuleTranslations = {
94104
[K.simbriefSuccess]: '已导入 {} → {}',
95105
[K.simbriefFailed]: '导入失败,请检查用户名或 Pilot ID',
96106
[K.simbriefNeedIdentity]: '请先填写 SimBrief 用户名或 Pilot ID',
107+
[K.profileTitle]: '航路气象剖面',
108+
[K.profileSubtitle]: '沿航线的高空风与温度,取自巡航高度',
109+
[K.profileWind]: '风速',
110+
[K.profileTailwind]: '顺风分量',
111+
[K.profileTemperature]: '温度',
112+
[K.profileLoading]: '正在取高空气象…',
113+
[K.profileNeedPlan]: '先导入 SimBrief 飞行计划',
114+
[K.profileFailed]: '高空气象取不到,稍后再试',
115+
[K.profileRefresh]: '重新获取',
116+
[K.profileCruise]: '巡航',
97117
[K.fieldArrival]: '到达机场',
98118
[K.fieldAlternate]: '备降机场',
99119
[K.fieldDepartureRunway]: '起飞跑道',
@@ -158,6 +178,16 @@ export const briefingTranslations: ModuleTranslations = {
158178
[K.simbriefSuccess]: 'Imported {} → {}',
159179
[K.simbriefFailed]: 'Import failed — check the username or Pilot ID',
160180
[K.simbriefNeedIdentity]: 'Enter a SimBrief username or Pilot ID first',
181+
[K.profileTitle]: 'Route weather profile',
182+
[K.profileSubtitle]: 'Upper winds and temperature along the route, at cruise level',
183+
[K.profileWind]: 'Wind',
184+
[K.profileTailwind]: 'Tailwind',
185+
[K.profileTemperature]: 'Temp',
186+
[K.profileLoading]: 'Fetching upper-air data...',
187+
[K.profileNeedPlan]: 'Import a SimBrief flight plan first',
188+
[K.profileFailed]: 'Upper-air data unavailable, try again later',
189+
[K.profileRefresh]: 'Reload',
190+
[K.profileCruise]: 'Cruise',
161191
[K.fieldArrival]: 'Arrival',
162192
[K.fieldAlternate]: 'Alternate',
163193
[K.fieldDepartureRunway]: 'Departure Runway',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* 航路气象剖面模型
3+
*
4+
* 沿计划航路取若干采样点,每点给出几个气压层上的风与温度。
5+
* 高度用的是上游实测的位势高度而非气压层的标准高度 ——
6+
* 气压面本身会被高低压顶起压低,那正是要看的东西。
7+
*/
8+
9+
/** 某个采样点在某一气压层上的气象 */
10+
export interface RouteProfileLevel {
11+
readonly pressureHPa: number;
12+
readonly altitudeFt: number;
13+
readonly temperatureC: number;
14+
readonly windSpeedKt: number;
15+
readonly windDirectionDeg: number;
16+
}
17+
18+
/** 航路上的一个采样点 */
19+
export interface RouteProfileSample {
20+
/** 在原始航路点数组里的下标 —— 靠它把剖面对回航路点名 */
21+
readonly index: number;
22+
readonly latitude: number;
23+
readonly longitude: number;
24+
readonly timeUtc?: string;
25+
readonly levels: readonly RouteProfileLevel[];
26+
}
27+
28+
/** 一次剖面查询的结果 */
29+
export interface RouteProfile {
30+
readonly samples: readonly RouteProfileSample[];
31+
readonly cached: boolean;
32+
}

src/modules/briefing/pages/briefing-page.module.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,29 @@
175175
flex: 1;
176176
min-width: 200px;
177177
}
178+
179+
/* ── 航路气象剖面 ── */
180+
181+
.profileHeadActions {
182+
display: flex;
183+
align-items: center;
184+
gap: 6px;
185+
}
186+
187+
.profileCruiseBadge {
188+
padding: 2px 7px;
189+
border-radius: 8px;
190+
border: 1px solid var(--color-primary-a30);
191+
background: var(--color-on-surface-a04);
192+
font-size: 10px;
193+
font-weight: 700;
194+
color: var(--color-primary);
195+
white-space: nowrap;
196+
}
197+
198+
.profileHint {
199+
padding: 26px 8px;
200+
text-align: center;
201+
font-size: 12px;
202+
color: var(--color-text-secondary);
203+
}

src/modules/briefing/pages/briefing-page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PersistenceService } from '../../../core/services/persistence-service';
22
import { usePlannedRouteStore } from '../../common/providers/planned-route-store';
3+
import { RouteProfilePanel } from './widgets/route-profile-panel';
34

45
/** SimBrief 身份存在简报模块的命名空间下 */
56
const SIMBRIEF_MODULE = 'briefing';
@@ -303,6 +304,9 @@ function GenerateView() {
303304
</div>
304305
</SectionCard>
305306

307+
{/* 剖面依赖导入的计划航路,所以紧跟在 SimBrief 卡片后面 */}
308+
<RouteProfilePanel />
309+
306310
<SectionCard title={t(K.inputTitle)} icon="edit_note" subtitle={t(K.generateSubtitle)}>
307311
<div className={styles.formGrid}>
308312
<AirportField
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* 航路气象剖面图
3+
*
4+
* 横轴是沿航线的航路点,纵轴左侧风速、右侧温度,都取巡航高度上的插值。
5+
* 另画一条顺风分量:这是三条线里唯一能直接回答「这趟省不省油」的。
6+
*
7+
* 为什么只画巡航高度而不画整个二维剖面:真正影响航程的就是巡航那一层,
8+
* 把八层全铺开会得到一张谁也读不出结论的热力图。想看某点的完整廓线,
9+
* 鼠标悬上去 tooltip 会列出各层。
10+
*/
11+
12+
import { useMemo } from 'react';
13+
import type { EChartsOption } from 'echarts';
14+
import { EChart } from '../../../../core/widgets/common/echart';
15+
import { useTranslate } from '../../../../core/localization/use-translate';
16+
import { BriefingLocalizationKeys as K } from '../../localization/briefing-localization';
17+
import type { PlannedRoute } from '../../../common/models/planned-route-models';
18+
import type { RouteProfileSample } from '../../models/route-profile-models';
19+
import {
20+
interpolateAtAltitude,
21+
tailwindComponentKt,
22+
} from '../../services/route-profile-parser';
23+
24+
/** 没给巡航高度时的兜底 —— 民航长航线的典型值 */
25+
const DEFAULT_CRUISE_FT = 35000;
26+
27+
/**
28+
* 两点间的大圆初始航向(度)。
29+
*
30+
* 用来算顺风分量。不能用「经纬度差的 atan2」那种平面近似:
31+
* 高纬度上经度差会被严重放大,欧亚航线上能偏出几十度。
32+
*/
33+
function initialBearingDeg(
34+
from: { latitude: number; longitude: number },
35+
to: { latitude: number; longitude: number },
36+
): number {
37+
const toRad = (deg: number) => (deg * Math.PI) / 180;
38+
const lat1 = toRad(from.latitude);
39+
const lat2 = toRad(to.latitude);
40+
const deltaLon = toRad(to.longitude - from.longitude);
41+
const y = Math.sin(deltaLon) * Math.cos(lat2);
42+
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon);
43+
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
44+
}
45+
46+
/** 采样点的横轴标签:优先用航路点名,取不到就用坐标 */
47+
function sampleLabel(sample: RouteProfileSample, plan: PlannedRoute): string {
48+
// index 0 是起飞机场,最后一个是落地机场,中间对应 plan.points
49+
if (sample.index === 0) return plan.origin.code;
50+
const pointIndex = sample.index - 1;
51+
if (pointIndex >= 0 && pointIndex < plan.points.length) {
52+
const ident = plan.points[pointIndex].ident.trim();
53+
if (ident.length > 0) return ident;
54+
}
55+
if (sample.index === plan.points.length + 1) return plan.destination.code;
56+
return `${sample.latitude.toFixed(1)},${sample.longitude.toFixed(1)}`;
57+
}
58+
59+
export function RouteProfileChart({
60+
samples,
61+
plan,
62+
}: {
63+
samples: readonly RouteProfileSample[];
64+
plan: PlannedRoute;
65+
}) {
66+
const t = useTranslate();
67+
const cruiseFt = plan.cruiseAltitudeFt ?? DEFAULT_CRUISE_FT;
68+
69+
const data = useMemo(() => {
70+
const labels: string[] = [];
71+
const windSpeed: (number | null)[] = [];
72+
const temperature: (number | null)[] = [];
73+
const tailwind: (number | null)[] = [];
74+
const detail: string[] = [];
75+
76+
samples.forEach((sample, i) => {
77+
labels.push(sampleLabel(sample, plan));
78+
const level = interpolateAtAltitude(sample, cruiseFt);
79+
if (!level) {
80+
// 缺数据留空洞,让折线自己断开 —— 补零会画出一条「无风」的假线
81+
windSpeed.push(null);
82+
temperature.push(null);
83+
tailwind.push(null);
84+
detail.push('');
85+
return;
86+
}
87+
windSpeed.push(Number(level.windSpeedKt.toFixed(1)));
88+
temperature.push(Number(level.temperatureC.toFixed(1)));
89+
90+
// 航向取「到下一点」;最后一点没有下一点,就沿用前一段的航向
91+
const next = samples[i + 1] ?? samples[i - 1];
92+
if (next) {
93+
const track =
94+
samples[i + 1] !== undefined
95+
? initialBearingDeg(sample, next)
96+
: initialBearingDeg(next, sample);
97+
tailwind.push(
98+
Number(
99+
tailwindComponentKt(level.windDirectionDeg, level.windSpeedKt, track).toFixed(1),
100+
),
101+
);
102+
} else {
103+
tailwind.push(null);
104+
}
105+
106+
detail.push(
107+
sample.levels
108+
.slice()
109+
.reverse()
110+
.map(
111+
(item) =>
112+
`FL${Math.round(item.altitudeFt / 100)}&nbsp;&nbsp;` +
113+
`${item.windDirectionDeg.toFixed(0).padStart(3, '0')}°/${item.windSpeedKt.toFixed(0)}kt&nbsp;&nbsp;` +
114+
`${item.temperatureC.toFixed(0)}°C`,
115+
)
116+
.join('<br/>'),
117+
);
118+
});
119+
120+
return { labels, windSpeed, temperature, tailwind, detail };
121+
}, [samples, plan, cruiseFt]);
122+
123+
const option = useMemo<EChartsOption>(
124+
() => ({
125+
grid: { left: 46, right: 46, top: 28, bottom: 46 },
126+
legend: {
127+
top: 0,
128+
textStyle: { fontSize: 10, color: 'var(--color-text-secondary)' },
129+
data: [t(K.profileWind), t(K.profileTailwind), t(K.profileTemperature)],
130+
},
131+
tooltip: {
132+
trigger: 'axis',
133+
// 悬停时列出该点的完整廓线:主图只画巡航那一层,其余层在这里看
134+
formatter: (params: unknown) => {
135+
const list = Array.isArray(params) ? params : [params];
136+
const first = list[0] as { dataIndex: number; axisValue: string } | undefined;
137+
if (!first) return '';
138+
const lines = list
139+
.map((item) => {
140+
const point = item as { seriesName: string; value: number | null };
141+
if (point.value === null || point.value === undefined) return '';
142+
return `${point.seriesName}: ${point.value}`;
143+
})
144+
.filter((line) => line.length > 0);
145+
const levels = data.detail[first.dataIndex];
146+
return (
147+
`<b>${first.axisValue}</b><br/>${lines.join('<br/>')}` +
148+
(levels ? `<br/><span style="opacity:.7">${levels}</span>` : '')
149+
);
150+
},
151+
},
152+
xAxis: {
153+
type: 'category',
154+
data: data.labels,
155+
axisLabel: { fontSize: 9, rotate: 45, interval: 0 },
156+
},
157+
yAxis: [
158+
{
159+
type: 'value',
160+
name: 'kt',
161+
nameTextStyle: { fontSize: 9 },
162+
axisLabel: { fontSize: 9 },
163+
splitLine: { lineStyle: { opacity: 0.12 } },
164+
},
165+
{
166+
type: 'value',
167+
name: '°C',
168+
nameTextStyle: { fontSize: 9 },
169+
axisLabel: { fontSize: 9 },
170+
splitLine: { show: false },
171+
},
172+
],
173+
series: [
174+
{
175+
name: t(K.profileWind),
176+
type: 'line',
177+
smooth: true,
178+
symbolSize: 4,
179+
data: data.windSpeed,
180+
lineStyle: { width: 2 },
181+
itemStyle: { color: '#38bdf8' },
182+
},
183+
{
184+
name: t(K.profileTailwind),
185+
type: 'line',
186+
smooth: true,
187+
symbolSize: 4,
188+
data: data.tailwind,
189+
lineStyle: { width: 2 },
190+
itemStyle: { color: '#4ade80' },
191+
// 零线分开顺风与顶风:线在上面省油,在下面费油
192+
markLine: {
193+
silent: true,
194+
symbol: 'none',
195+
data: [{ yAxis: 0 }],
196+
lineStyle: { color: '#9aa4b2', type: 'dashed', opacity: 0.5 },
197+
label: { show: false },
198+
},
199+
},
200+
{
201+
name: t(K.profileTemperature),
202+
type: 'line',
203+
smooth: true,
204+
symbolSize: 4,
205+
yAxisIndex: 1,
206+
data: data.temperature,
207+
lineStyle: { width: 2, type: 'dotted' },
208+
itemStyle: { color: '#fbbf24' },
209+
},
210+
],
211+
}),
212+
[data, t],
213+
);
214+
215+
return <EChart option={option} height={260} />;
216+
}

0 commit comments

Comments
 (0)