|
| 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)} ` + |
| 113 | + `${item.windDirectionDeg.toFixed(0).padStart(3, '0')}°/${item.windSpeedKt.toFixed(0)}kt ` + |
| 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