diff --git a/libs/windy-sounding/package.json b/libs/windy-sounding/package.json index 4194da64..77185f1a 100644 --- a/libs/windy-sounding/package.json +++ b/libs/windy-sounding/package.json @@ -1,6 +1,6 @@ { "name": "windy-plugin-fxc-soundings", - "version": "5.0.5", + "version": "5.0.6", "type": "module", "private": true, "description": "Alternative sounding graphs with custom features for PG/HG pilots.", diff --git a/libs/windy-sounding/src/components/skewt.tsx b/libs/windy-sounding/src/components/skewt.tsx index d7ddf558..e1f8e58e 100644 --- a/libs/windy-sounding/src/components/skewt.tsx +++ b/libs/windy-sounding/src/components/skewt.tsx @@ -11,6 +11,7 @@ export type SkewTProps = { height: number; yPointer: number | undefined; levels: number[]; + cloudLevels: number[]; temps: number[]; dewPoints: number[]; ghs: number[]; @@ -36,6 +37,7 @@ export function SkewT(props: SkewTProps) { width, height, levels, + cloudLevels, temps, ghs, dewPoints, @@ -190,7 +192,7 @@ export function SkewT(props: SkewTProps) { ), - [width, levels, clouds, pressureToPxScale, pressureToGhScale, surfaceElevation, showUpperClouds], + [width, cloudLevels, clouds, pressureToPxScale, pressureToGhScale, surfaceElevation, showUpperClouds], ); const linesElement = useMemo( @@ -508,64 +510,92 @@ function AltitudeAxis({ export type CloudsProp = { width: number; - levels: number[]; + cloudLevels: number[]; clouds: number[]; pressureToPxScale: Scale; surfacePressure: number; showUpperClouds: boolean; }; -function Clouds({ width, levels, clouds, pressureToPxScale, surfacePressure, showUpperClouds }: CloudsProp) { - const rects = []; - const pressureToCloudScale = math.scaleLinear(levels, clouds); +const CLOUD_COLOR = '#666'; +const MIN_UPPER_CLOUD_COVER = 5; +const MAX_OPACITY = 0.75; + +/** + * Calculate the opacity of a cloud based on its cloud cover. + * @param cloudCover The cloud cover, as a percentage. + * @returns The opacity of the cloud, as a string between 0.00 and MAX_OPACITY with 2 decimals precision. + */ +function cloudOpacity(cloudCover: number): string { + return ((cloudCover / 100) * MAX_OPACITY).toFixed(2); +} + +function Clouds({ width, cloudLevels, clouds, pressureToPxScale, surfacePressure, showUpperClouds }: CloudsProp) { + if (cloudLevels.length === 0 || clouds.length === 0) { + return null; + } + const elements = []; + const pressureToCloudScale = math.scaleLog(cloudLevels, clouds); - let y = 0; + let yStart = 0; if (showUpperClouds) { - y = 30; - const upperPressure = pressureToPxScale.invert(y); - const upperCoverMax = Math.max(0, ...levels.map((level, i) => (level <= upperPressure ? clouds[i] ?? 0 : 0))); - - if (upperCoverMax >= 5) { - const opacity = (upperCoverMax / 100) * 0.75; - rects.push( - , - + yStart = 30; + const upperPressure = pressureToPxScale.invert(yStart); + const upperCoverMax = Math.max(0, ...cloudLevels.map((level, i) => (level <= upperPressure ? clouds[i] ?? 0 : 0))); + + if (upperCoverMax >= MIN_UPPER_CLOUD_COVER) { + elements.push( + , + Upper , - , - , + , + , ); } } - // Then respect the y scale - const surfaceY = pressureToPxScale(surfacePressure); - while (y < surfaceY) { - const startY = y; - const cloudPct = Math.max(0, Math.min(100, pressureToCloudScale(pressureToPxScale.invert(y)))); - let layerHeight = 1; - while (y++ < surfaceY) { - const nextPct = Math.max(0, Math.min(100, pressureToCloudScale(pressureToPxScale.invert(y)))); - if (Math.abs(nextPct - cloudPct) > 2 || nextPct >= 5 !== cloudPct >= 5) { - break; - } - layerHeight++; - } - if (cloudPct >= 5) { - const opacity = (cloudPct / 100) * 0.75; - rects.push(); + // Render cloud column using a linear gradient with stops at each pressure level. + const surfaceY = Math.round(pressureToPxScale(surfacePressure)); + const columnHeight = surfaceY - yStart; + + if (columnHeight > 0) { + const intermediatePoints = cloudLevels + .map((pressure) => ({ pressure, y: pressureToPxScale(pressure) })) + .filter(({ y }) => y >= yStart + 1 && y <= surfaceY - 1) + .sort((a, b) => a.y - b.y); + + const columnPoints = [ + { pressure: pressureToPxScale.invert(yStart), y: yStart }, + ...intermediatePoints, + { pressure: surfacePressure, y: surfaceY }, + ]; + + const stops = columnPoints.map(({ pressure, y }) => { + const cloudCover = Math.max(0, Math.min(100, pressureToCloudScale(pressure))); + return { + offset: (y - yStart) / columnHeight, + opacity: cloudOpacity(cloudCover), + }; + }); + + // Only render the cloud rectangle if there is visible cloud cover + if (stops.some((s) => s.opacity > 0)) { + elements.push( + + + {stops.map((stop, idx) => ( + + ))} + + , + , + ); } } - return {rects}; -} - -function Cloud({ y, height, width, opacity }: { y: number; height: number; width: number; opacity: number }) { - if (opacity <= 0) { - return; - } - return ; + return {elements}; } function Cirrus({ x, y, scale }: { x: number; y: number; scale: number }) { diff --git a/libs/windy-sounding/src/containers/containers.tsx b/libs/windy-sounding/src/containers/containers.tsx index 6575739e..8e3a7667 100644 --- a/libs/windy-sounding/src/containers/containers.tsx +++ b/libs/windy-sounding/src/containers/containers.tsx @@ -347,7 +347,7 @@ function Graph({ width, height, skewTWidthPercent }: { width: number; height: nu // Only update the active map level for overlays that support multiple altitude levels // (e.g. wind, temp) to avoid unnecessary or invalid level changes on surface-only layers (e.g. rain, radar). - const overlay = W.store.get('overlay') as keyof typeof W.overlays; + const overlay: keyof typeof W.overlays = W.store.get('overlay'); const supportsLevels = W.overlays[overlay].hasMoreLevels; const availLevels = W.store.get('availLevels'); if (supportsLevels && availLevels.length > 1) { @@ -433,6 +433,7 @@ const ConnectedSkewT = memo(function ConnectedSkewT(props: ChildGraphProps) { return { levels: periodValues.levels, + cloudLevels: periodValues.cloudLevels, temps: timeValues.temp, dewPoints: timeValues.dewPoint, ghs: timeValues.gh, diff --git a/libs/windy-sounding/src/redux/forecast-slice.ts b/libs/windy-sounding/src/redux/forecast-slice.ts index bbf49cef..084829e2 100644 --- a/libs/windy-sounding/src/redux/forecast-slice.ts +++ b/libs/windy-sounding/src/redux/forecast-slice.ts @@ -29,26 +29,40 @@ export enum FetchStatus { } // Those properties varies with the altitude level. -const _levelProps = ['temp', 'dewPoint', 'gh', 'wind', 'windDir', 'rh', 'cloud'] as const; +// All are defined at the same levels (the `PeriodValue.levels` array). +const _levelProps = ['temp', 'dewPoint', 'gh', 'wind', 'windDir', 'rh'] as const; type LevelProp = (typeof _levelProps)[number]; type LevelPropByTime = `${LevelProp}ByTime`; +// Those properties varies with the cloud altitude level. +// All are defined at the same levels (the `PeriodValue.cloudLevels` array). +// Note that `PeriodValue.levels` and `PeriodValue.cloudLevels` can have different sizes/values. +const _cloudLevelProps = ['cloud'] as const; +type CloudLevelProp = (typeof _cloudLevelProps)[number]; +type CloudLevelPropByTime = `${CloudLevelProp}ByTime`; + // Those properties do not vary with altitude. const _sfcProps = ['rainMm', 'seaLevelPressure'] as const; type SfcProps = (typeof _sfcProps)[number]; type SfcPropsByTime = `${SfcProps}ByTime`; -type TimeValue = Record & Record; +// Values for a given time step. +type TimeValue = Record & Record; type ForecastType = WeatherDataPayload2; +// Values aggregated over all time steps (e.g. max/min over time) export type PeriodValue = { maxTemp: number; minTemp: number; maxSeaLevelPressure: number; + // Levels for `LevelProp` levels: number[]; + // Levels for `CloudLevelProp` + cloudLevels: number[]; timesMs: number[]; } & Record & // By time then by level + Record & // By time then by cloud level // By time only Record; @@ -244,7 +258,7 @@ function isWindyDataCached(state: ForecastState, key: string) { */ function extractSoundingParamByLevel( sounding: SoundingDataHash2, - paramName: LevelProp, + paramName: LevelProp | CloudLevelProp, levels: number[], tsIndex: number, ): number[] { @@ -282,7 +296,7 @@ function extractSoundingParamByLevel( value = sounding[`windDir-${levelKey}`]?.[tsIndex]; break; case 'cloud': - value = sounding[`cloud-${levelKey}`]?.[tsIndex] ?? 0; + value = sounding[`cloud-${levelKey}`]?.[tsIndex]; break; } @@ -303,6 +317,7 @@ function computePeriodValues( fetchStatus: FetchStatus.Loaded; }, levels: number[], + cloudLevels: number[], ): PeriodValue { if (!windyData.forecast.sounding?.ts) { throw new Error('Invalid forecast data: No sounding timestamps found.'); @@ -315,7 +330,9 @@ function computePeriodValues( let minTemp: number = Number.MAX_VALUE; let maxSeaLevelPressure: number = Number.MIN_VALUE; - const values: Record & Record = { + const values: Record & + Record & + Record = { dewPointByTime: [], ghByTime: [], rhByTime: [], @@ -340,7 +357,7 @@ function computePeriodValues( values.rhByTime.push(extractSoundingParamByLevel(soundingData, 'rh', levels, tsIndex)); values.windByTime.push(extractSoundingParamByLevel(soundingData, 'wind', levels, tsIndex)); values.windDirByTime.push(extractSoundingParamByLevel(soundingData, 'windDir', levels, tsIndex)); - values.cloudByTime.push(extractSoundingParamByLevel(soundingData, 'cloud', levels, tsIndex)); + values.cloudByTime.push(extractSoundingParamByLevel(soundingData, 'cloud', cloudLevels, tsIndex)); values.rainMmByTime.push(sampleAt(soundingTimeMs, windyData.forecast.data.precipAmount, timeMs)); values.seaLevelPressureByTime.push(Math.round(seaLevelPressure)); } @@ -348,6 +365,7 @@ function computePeriodValues( return { timesMs: soundingTimeMs, levels, + cloudLevels, maxTemp, minTemp, maxSeaLevelPressure, @@ -454,6 +472,25 @@ export const selDescendingLevels = createSelector(selLoadedWindyDataOrThrow, (wi ); }); +export const selCloudDescendingLevels = createSelector(selLoadedWindyDataOrThrow, (windyData): number[] => { + const sounding = windyData.forecast.sounding; + if (!sounding?.ts) { + return []; + } + const numTimestamps = sounding.ts.length; + return ( + Object.keys(sounding) + .filter((key: string) => key.startsWith('cloud-') && key.endsWith('h')) + // Only keep levels with non-null values for all timestamps + .filter((key: string) => { + const values = (sounding as Record)[key]; + return Array.isArray(values) && values.length >= numTimestamps && values.every((v) => v != null); + }) + .map((key: string) => parseInt(key.slice(6, -1), 10)) + .sort((a: number, b: number) => b - a) + ); +}); + export const selMaxModelPressure = createSelector( selDescendingLevels, (descendingLevels): number => descendingLevels[0], @@ -467,7 +504,8 @@ export const selMinModelPressure = createSelector( export const selPeriodValues = createSelector( selLoadedWindyDataOrThrow, selDescendingLevels, - (windyData, levels): PeriodValue => computePeriodValues(windyData, levels), + selCloudDescendingLevels, + (windyData, levels, cloudLevels): PeriodValue => computePeriodValues(windyData, levels, cloudLevels), ); export const selMaxPeriodTemp = createSelector(selPeriodValues, (periodValues): number => periodValues.maxTemp);