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);