@opengeoweb/soundings 15.2.0 → 15.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ There, you can specify the required details for each source under the `soundings
15
15
  "id": "harmonie_scandinavia_hybrid",
16
16
  "url": "https://opendata.fmi.fi/edr",
17
17
  "description": "MEPS/vertical coordinate model level",
18
+ "windSpeedUnit": "ms",
18
19
  "parameterMappings": {
19
20
  "temperature": "temperature",
20
21
  "windDirection": "winddirection",
@@ -28,6 +29,7 @@ There, you can specify the required details for each source under the `soundings
28
29
  "id": "harmonie_scandinavia_pressure",
29
30
  "url": "https://opendata.fmi.fi/edr",
30
31
  "description": "MEPS/vertical coordinate pressure",
32
+ "windSpeedUnit": "ms",
31
33
  "parameterMappings": {
32
34
  "temperature": "temperature",
33
35
  "windDirection": "winddirection",
@@ -39,7 +41,9 @@ There, you can specify the required details for each source under the `soundings
39
41
  ]
40
42
  ```
41
43
 
42
- Each source should have a name, id, source url and description and include parameter mappings telling the soundings API which EDR parameters to assign to which physical property.
44
+ Each source should have a `name`, `id`, `source url`, `description`, `windSpeedUnit` and include parameter mappings telling the soundings API which EDR parameters to assign to which physical property.
45
+
46
+ The `windSpeedUnit` is required to ensure correct derivation of the windbarbs and hodograph. This should contain either 'ms' or 'kts' depending on the unit served by the EDR service
43
47
 
44
48
  The following mappings are needed:
45
49
 
package/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import { Menu, ListSubheader, Typography, MenuItem, Box, Card, Stack, IconButton, Tooltip, LinearProgress, List, ListItem, ListItemIcon, Checkbox, ListItemText, Grid, ListItemButton } from '@mui/material';
2
+ import { Menu, ListSubheader, Typography, MenuItem, Box, Stack, Card, IconButton, Tooltip, LinearProgress, List, ListItem, ListItemIcon, Checkbox, ListItemText, Grid, ListItemButton } from '@mui/material';
3
3
  import { range, minBy, isEqual } from 'lodash';
4
4
  import React, { useRef, useEffect, useMemo, useState, Fragment as Fragment$1 } from 'react';
5
5
  import { useDispatch, useSelector, shallowEqual } from 'react-redux';
@@ -326,7 +326,7 @@ const getDiagramName = (t, diagramType) => {
326
326
  }
327
327
  };
328
328
  // vfx code
329
- const initializeSvg = ({
329
+ const initializeSvg$1 = ({
330
330
  svgRef,
331
331
  width,
332
332
  height,
@@ -345,7 +345,7 @@ const initializeSvg = ({
345
345
  svg.append('rect').attr('height', height).attr('width', width).attr('fill', '#ffffff00').attr('opacity', 1).attr('id', `${d3GraphId}-${diagramType}d3`).attr('clip-path', `url(#clip-${d3GraphId}-${diagramType}d3)`);
346
346
  return svg;
347
347
  };
348
- const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
348
+ const createScales$1 = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
349
349
  if (diagramType === 'BIJVOET') {
350
350
  // Note, for Bijvoet, the x-axis is NOT temperature but theta_es
351
351
  // Both axis aren't linear nor logarithmic, hence we need an interpolation function to determine (x,y) based on (theta_es,p)
@@ -358,7 +358,7 @@ const createScales = (diagramType, width, height, minPressure, maxPressure, minT
358
358
  const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
359
359
  return [xScale, yScale];
360
360
  };
361
- const drawAxes = ({
361
+ const drawAxes$1 = ({
362
362
  svg,
363
363
  xScale,
364
364
  yScale,
@@ -697,7 +697,7 @@ const drawSoundingsTooltip = ({
697
697
  // Wind
698
698
  windRefd3?.style('display', 'block').style('left', `${width + margin.left + margin.right}px`).style('top', `${pressureY}px`).html(() => {
699
699
  const windSpeedStrings = interpolatedData.map((dataSet, index) => {
700
- return getParamHtml(dataSet.hoveredWindSpeed, index, 'm/s', index === interpolatedData.length - 1, dataSet.color, true);
700
+ return getParamHtml(dataSet.hoveredWindSpeed, index, 'kts', index === interpolatedData.length - 1, dataSet.color, true);
701
701
  });
702
702
  const windDirStrings = interpolatedData.map((dataSet, index) => {
703
703
  return getParamHtml(dataSet.hoveredWindDirection, index, '°', index === interpolatedData.length - 1, dataSet.color, index === interpolatedData.length - 1);
@@ -1231,7 +1231,7 @@ const Soundings = ({
1231
1231
  if (!svgRef.current) {
1232
1232
  return;
1233
1233
  }
1234
- const svg = initializeSvg({
1234
+ const svg = initializeSvg$1({
1235
1235
  svgRef,
1236
1236
  width,
1237
1237
  height,
@@ -1245,9 +1245,9 @@ const Soundings = ({
1245
1245
  widthRef.current = width;
1246
1246
  const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
1247
1247
  const maxT = maxTemperature;
1248
- const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1248
+ const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1249
1249
  yScaleRef.current = yScale;
1250
- drawAxes({
1250
+ drawAxes$1({
1251
1251
  svg,
1252
1252
  xScale,
1253
1253
  yScale,
@@ -1334,7 +1334,7 @@ const Soundings = ({
1334
1334
  const dewpointRefd3 = d3.select(dewpointRef.current);
1335
1335
  const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
1336
1336
  const maxT = maxTemperature;
1337
- const [, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1337
+ const [, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1338
1338
  drawSoundingsTooltip({
1339
1339
  event: synthetic,
1340
1340
  yScale,
@@ -1738,7 +1738,6 @@ const windSpeedRanges = [{
1738
1738
  * Copyright 2025 - Finnish Meteorological Institute (FMI)
1739
1739
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1740
1740
  * */
1741
- const metersPerSecondToKnots = metersPerSecond => metersPerSecond * 1.94384;
1742
1741
  const extendIsoBar = (path, horizontalBarbMargin) => {
1743
1742
  const match = /^M([\d.-]+),([\d.-]+)\s*L([\d.-]+),([\d.-]+)/.exec(path);
1744
1743
  if (match) {
@@ -1846,11 +1845,10 @@ const removeItemsExponential = array => {
1846
1845
  return array.filter((_, index) => indicesToKeep.includes(index));
1847
1846
  };
1848
1847
  const getWindSymbol = value => {
1849
- const valueKts = metersPerSecondToKnots(value);
1850
1848
  const matchedRange = windSpeedRanges.find(({
1851
1849
  min,
1852
1850
  max
1853
- }) => valueKts >= min && valueKts < max);
1851
+ }) => value >= min && value < max);
1854
1852
  // Out of range
1855
1853
  if (!matchedRange) {
1856
1854
  return null;
@@ -1929,7 +1927,7 @@ const WindBarbs = ({
1929
1927
  }
1930
1928
  const root = d3.select(svgRef.current);
1931
1929
  root.selectAll('*').remove();
1932
- const svg = initializeSvg({
1930
+ const svg = initializeSvg$1({
1933
1931
  svgRef,
1934
1932
  width,
1935
1933
  height,
@@ -1947,7 +1945,7 @@ const WindBarbs = ({
1947
1945
  widthRef.current = width;
1948
1946
  const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
1949
1947
  const maxT = maxTemperature;
1950
- const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1948
+ const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1951
1949
  yScaleRef.current = yScale;
1952
1950
  drawIsobars({
1953
1951
  diagramType,
@@ -2040,6 +2038,257 @@ const WindBarbs = ({
2040
2038
  });
2041
2039
  };
2042
2040
 
2041
+ /* *
2042
+ * Licensed under the Apache License, Version 2.0 (the "License");
2043
+ * you may not use this file except in compliance with the License.
2044
+ * You may obtain a copy of the License at
2045
+ *
2046
+ * http://www.apache.org/licenses/LICENSE-2.0
2047
+ *
2048
+ * Unless required by applicable law or agreed to in writing, software
2049
+ * distributed under the License is distributed on an "AS IS" BASIS,
2050
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2051
+ * See the License for the specific language governing permissions and
2052
+ * limitations under the License.
2053
+ *
2054
+ * Copyright 2026 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
2055
+ * Copyright 2026 - Finnish Meteorological Institute (FMI)
2056
+ * Copyright 2026 - The Norwegian Meteorological Institute (MET Norway)
2057
+ * */
2058
+ const initializeSvg = ({
2059
+ svgRef,
2060
+ widthHeight
2061
+ }) => {
2062
+ const svg = d3.select(svgRef.current).attr('data-testid', 'hodograph').attr('width', '100%').attr('height', '100%').attr('viewBox', `0 0 ${widthHeight + 3} ${widthHeight + 3}`).attr('style', 'width: 100%; height: auto;');
2063
+ const gSel = svg.select('g.hodograph');
2064
+ if (gSel.empty()) {
2065
+ svg.append('g').attr('class', 'hodograph');
2066
+ }
2067
+ return svg;
2068
+ };
2069
+ const createScales = widthHeight => {
2070
+ const scaleRadius = d3.scaleLinear().domain([0, 100]).range([0, widthHeight / 2]);
2071
+ const scaleAngle = d3.scaleLinear().domain([0, 360]).range([0, 2 * Math.PI]);
2072
+ return [scaleRadius, scaleAngle];
2073
+ };
2074
+ const drawAxes = ({
2075
+ svg,
2076
+ scaleRadius,
2077
+ scaleAngle,
2078
+ widthHeight
2079
+ }) => {
2080
+ // Draw circles
2081
+ svg.select('g').selectAll('circle').data([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]).join(enter => enter.append('circle').attr('cx', widthHeight / 2).attr('cy', widthHeight / 2).attr('fill', 'none').attr('stroke', 'gray').style('opacity', 0.6).attr('stroke-dasharray', data => data === 50 || data === 100 ? 'none' : '5, 4').attr('r', data => scaleRadius(data)));
2082
+ // Draw windspeed scale
2083
+ const speedTicks = scaleRadius.ticks(9);
2084
+ svg.select('g').append('g').attr('type', 'windspeed').attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`) // origin transformed to center
2085
+ .call(d3.axisBottom(scaleRadius).tickValues(speedTicks).tickSizeInner(0).tickSizeOuter(0))
2086
+ // Draw unit label
2087
+ .append('text').attr('fill', 'currentColor').attr('x', scaleRadius(90)).attr('y', '0').attr('font-size', '11px').attr('dy', '2.2em').attr('dx', '0.8em').attr('text-anchor', 'left').text('kts');
2088
+ };
2089
+ // Use this function to filter the pressure and parameter array so no NaN or null values are included
2090
+ const filterDomainRangeForHodograph = (pressureDomain, parameterDomain) => {
2091
+ const filteredPressureDomain = pressureDomain.filter((_value, index) => parameterDomain[index] !== null && !isNaN(parameterDomain[index][0]) && parameterDomain[index][0] !== null && !isNaN(parameterDomain[index][1]) && parameterDomain[index][1] !== null);
2092
+ const filteredParameterDomain = parameterDomain.filter(value => !isNaN(value[0]) && value[0] !== null && !isNaN(value[1]) && value[1] !== null);
2093
+ // If first pressure value is smallest, reverse arrays to ensure we draw starting from surface
2094
+ if (filteredPressureDomain[0] < filteredPressureDomain[filteredPressureDomain.length - 1]) {
2095
+ return [filteredPressureDomain.reverse(), filteredParameterDomain.reverse()];
2096
+ }
2097
+ return [filteredPressureDomain, filteredParameterDomain];
2098
+ };
2099
+ const T0_SEA = 288.15; // Standard temperature in Kelvin (15 degrees Celsius)
2100
+ const P0_SEA = 101325; // Reference pressure (Pa)
2101
+ const R_GAS = 8.314462618; // Universal gas constant (J/mol K)
2102
+ const L_TEMP = 0.0065; // Temperature gradient (K/m) at sea level
2103
+ const G_ACC = 9.80665; // The gravitational acceleration in units of geopotential height (m/s2)
2104
+ const M_AIR = 0.0289647; // Mean molar mass of air at sea level (kg/mol)
2105
+ const exponent = R_GAS * L_TEMP / (G_ACC * M_AIR); // ≈ 0.1903
2106
+ // This function returns the altitude above MSL using the barometric formula in m
2107
+ // Pressure passed in is in hPa, which is converted to Pa (pressure * 100)
2108
+ const altitudeBarometricFromPressure = pressure => {
2109
+ const altitude = T0_SEA / L_TEMP * ((P0_SEA / (pressure * 100)) ** exponent - 1);
2110
+ return altitude;
2111
+ };
2112
+ const generateSegmentsBasedOnLevel = (pressure, windValues) => {
2113
+ const segments = [[], [], [], [], []];
2114
+ pressure.forEach((pressureVal, index) => {
2115
+ // Ensure we ignore the last value
2116
+ if (pressure[index + 1]) {
2117
+ const heightInMeters = altitudeBarometricFromPressure(pressureVal);
2118
+ // index 0, red line
2119
+ if (heightInMeters < 1000) {
2120
+ segments[0].push(windValues[index]);
2121
+ segments[0].push(windValues[index + 1]);
2122
+ }
2123
+ // index 1, green line
2124
+ if (heightInMeters >= 1000 && heightInMeters < 3000) {
2125
+ segments[1].push(windValues[index]);
2126
+ segments[1].push(windValues[index + 1]);
2127
+ }
2128
+ // index 2, yellow line
2129
+ if (heightInMeters >= 3000 && heightInMeters < 6000) {
2130
+ segments[2].push(windValues[index]);
2131
+ segments[2].push(windValues[index + 1]);
2132
+ }
2133
+ // index 3, purple line
2134
+ if (heightInMeters >= 6000 && heightInMeters < 9000) {
2135
+ segments[3].push(windValues[index]);
2136
+ segments[3].push(windValues[index + 1]);
2137
+ }
2138
+ // index 4, black line
2139
+ if (heightInMeters >= 9000 && heightInMeters <= 19000) {
2140
+ segments[4].push(windValues[index]);
2141
+ segments[4].push(windValues[index + 1]);
2142
+ }
2143
+ }
2144
+ });
2145
+ return segments;
2146
+ };
2147
+ const HODOGRAPH_COLORS = ['red', 'olivedrab', 'orange', 'darkviolet', 'black'];
2148
+ const getColorForSegment = index => {
2149
+ if (HODOGRAPH_COLORS[index]) {
2150
+ return HODOGRAPH_COLORS[index];
2151
+ }
2152
+ return 'black';
2153
+ };
2154
+ const plotHodograph = ({
2155
+ windValues,
2156
+ pressureValues,
2157
+ scaleAngle,
2158
+ scaleRadius,
2159
+ widthHeight,
2160
+ svg,
2161
+ index
2162
+ }) => {
2163
+ // Filter out any NaN or null values
2164
+ const [filteredPressureValues, filteredWindValues] = filterDomainRangeForHodograph(pressureValues, windValues);
2165
+ // Construct path segments to be able to give each segment it's own color
2166
+ const pathSegments = generateSegmentsBasedOnLevel(filteredPressureValues, filteredWindValues);
2167
+ // index 0 = wind direction, index 1 = wind speed
2168
+ const lineGenerator = d3.lineRadial().angle(dataPoint => scaleAngle(dataPoint[0])).radius(dataPoint => scaleRadius(dataPoint[1]));
2169
+ svg.select('g.hodograph').selectAll(`path.dataline-${index}`).data(pathSegments).enter().append('path').classed(`dataline-${index}`, true).attr('data-testid', `hodograph-dataline-${index}`).attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`).attr('fill', 'none').attr('stroke', (data, segmentIndex) => getColorForSegment(segmentIndex)).attr('stroke-width', 2).style('stroke-opacity', 1).attr('d', data => lineGenerator(data));
2170
+ // Adding a dot for the very first data point in the color used in the soundings graph
2171
+ const firstDataPoint = filteredWindValues[0];
2172
+ if (firstDataPoint) {
2173
+ const color = SOUNDING_COLORS[index] || SOUNDING_COLORS[0];
2174
+ svg.select('g.hodograph').append('g').attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`) // origin transformed to center
2175
+ .attr('fill', color).attr('stroke', 'none').attr('data-testid', `hodograph-datastart-${index}`).selectAll().data([firstDataPoint]).join('circle').attr('cx', dataPoint => {
2176
+ const angle = scaleAngle(dataPoint[0]);
2177
+ const radius = scaleRadius(dataPoint[1]);
2178
+ return radius * Math.sin(angle);
2179
+ }).attr('cy', dataPoint => {
2180
+ const angle = scaleAngle(dataPoint[0]);
2181
+ const radius = scaleRadius(dataPoint[1]);
2182
+ return radius * -1 * Math.cos(angle);
2183
+ }).attr('r', 3);
2184
+ }
2185
+ };
2186
+
2187
+ const Hodograph = ({
2188
+ data
2189
+ }) => {
2190
+ const svgRef = useRef(null);
2191
+ const widthHeight = 400;
2192
+ useEffect(() => {
2193
+ if (!svgRef.current) {
2194
+ return;
2195
+ }
2196
+ // Define svg
2197
+ const svg = initializeSvg({
2198
+ svgRef,
2199
+ widthHeight
2200
+ });
2201
+ // Set scales for temperature and pressure
2202
+ const [scaleRadius, scaleAngle] = createScales(widthHeight);
2203
+ // Add axis'
2204
+ drawAxes({
2205
+ svg,
2206
+ scaleRadius,
2207
+ scaleAngle,
2208
+ widthHeight
2209
+ });
2210
+ data.forEach((features, index) => {
2211
+ const windValues = features?.map(feature => {
2212
+ // Transform windDirectionFrom to windDirectionTo
2213
+ const windDirectionTo = ((feature.properties.windDirection + 180) % 360 + 360) % 360;
2214
+ return [windDirectionTo, feature.properties.windSpeed];
2215
+ });
2216
+ const pressureValues = features.map(feature => feature.properties.pressure);
2217
+ // Draw Temperature
2218
+ plotHodograph({
2219
+ windValues,
2220
+ pressureValues,
2221
+ scaleAngle,
2222
+ scaleRadius,
2223
+ widthHeight,
2224
+ svg,
2225
+ index
2226
+ });
2227
+ });
2228
+ // Allow zooming and panning
2229
+ const zoomBehavior = d3.zoom().scaleExtent([1, 7]).translateExtent([[-0.2 * widthHeight, -0.2 * widthHeight], [1.2 * widthHeight, 1.2 * widthHeight]]).on('zoom', event => {
2230
+ svg.select('g.hodograph').attr('transform', event.transform);
2231
+ });
2232
+ svg.call(zoomBehavior);
2233
+ // eslint-disable-next-line consistent-return
2234
+ return () => {
2235
+ svg.on('zoom', null);
2236
+ svg.select('g.hodograph').selectAll('*').remove();
2237
+ };
2238
+ }, [data, widthHeight]);
2239
+ return jsx("svg", {
2240
+ height: "100%",
2241
+ ref: svgRef,
2242
+ width: "100%"
2243
+ });
2244
+ };
2245
+
2246
+ const getLegendItem = (color, text) => {
2247
+ return jsxs(Typography, {
2248
+ sx: {
2249
+ color: 'geowebColors.typographyAndIcons.text',
2250
+ fontSize: '9px'
2251
+ },
2252
+ variant: "caption",
2253
+ children: [jsx(Box, {
2254
+ sx: {
2255
+ width: '16px',
2256
+ height: '4px',
2257
+ backgroundColor: color,
2258
+ display: 'inline-block',
2259
+ marginRight: '4px',
2260
+ marginBottom: '2px'
2261
+ }
2262
+ }), text]
2263
+ });
2264
+ };
2265
+ const HodographLegend = () => {
2266
+ return jsxs(Stack, {
2267
+ direction: "column",
2268
+ justifyContent: "center",
2269
+ sx: {
2270
+ flexWrap: 'wrap',
2271
+ paddingLeft: 2,
2272
+ paddingTop: 0.5
2273
+ },
2274
+ useFlexGap: true,
2275
+ children: [getLegendItem(HODOGRAPH_COLORS[0], '0 - 1 km'), getLegendItem(HODOGRAPH_COLORS[1], '1 - 3 km'), getLegendItem(HODOGRAPH_COLORS[2], '3 - 6 km'), getLegendItem(HODOGRAPH_COLORS[3], '6 - 9 km'), getLegendItem(HODOGRAPH_COLORS[4], '9 - 19 km')]
2276
+ });
2277
+ };
2278
+
2279
+ const HodographCollection = ({
2280
+ data
2281
+ }) => {
2282
+ return jsxs(Box, {
2283
+ sx: {
2284
+ padding: '5px'
2285
+ },
2286
+ children: [jsx(Hodograph, {
2287
+ data: data
2288
+ }), jsx(HodographLegend, {})]
2289
+ });
2290
+ };
2291
+
2043
2292
  const ACCORDION_MIN_WIDTH = 24;
2044
2293
  const SoundingsAccordion = React.forwardRef(({
2045
2294
  name,
@@ -2353,6 +2602,14 @@ const SortableAccordions = ({
2353
2602
  }
2354
2603
  })
2355
2604
  });
2605
+ case 'hodograph':
2606
+ return jsx(SoundingsAccordion, {
2607
+ maxWidth: 500,
2608
+ name: "Hodograph",
2609
+ children: jsx(HodographCollection, {
2610
+ data: data
2611
+ })
2612
+ });
2356
2613
  default:
2357
2614
  return null;
2358
2615
  }
@@ -2558,6 +2815,7 @@ const aConstant = 17.67;
2558
2815
  const bConstant = 243.5; // Constants for water, b in Celsius
2559
2816
  const gConstant = 461.5; // Gas constant ratio between water vapour and air [J/(K*kg)]
2560
2817
  const hConstant = 2.5e6; // Latent heat for water vaporization [J/kg]
2818
+ const ktsConstant = 1.94384;
2561
2819
  // Formula to use when dewpoint is not available
2562
2820
  // Known as Magnus Formula
2563
2821
  // - Calculate vapor pressure
@@ -2582,8 +2840,15 @@ relHumidity) {
2582
2840
  const dewpoint = tKelvin / (1 - tKelvin * Math.log(relHumidity * 0.01) * gConstant / hConstant);
2583
2841
  return dewpoint;
2584
2842
  }
2843
+ const getWindSpeedInKts = (windSpeed, windSpeedUnit) => {
2844
+ if (windSpeedUnit === 'kts') {
2845
+ return windSpeed;
2846
+ }
2847
+ // Formula to convert from m/s to kts
2848
+ return windSpeed * ktsConstant;
2849
+ };
2585
2850
  // Parsing and conversion function
2586
- function parseCoverageCollection(response, parameterMappings) {
2851
+ function parseCoverageCollection(response, parameterMappings, windSpeedUnit) {
2587
2852
  const features = [];
2588
2853
  const pressureArray = [];
2589
2854
  const tempArray = [];
@@ -2687,13 +2952,14 @@ function parseCoverageCollection(response, parameterMappings) {
2687
2952
  dewpoint = dewpointFromRelativeHumidity(tempArray[index], relativeHumidity);
2688
2953
  }
2689
2954
  // Extract wind speed and direction - use fallback calculations if no speed and direction in parameter mappings
2955
+ // Convert to kts if retrieved in m/s
2690
2956
  let windSpeed = NaN;
2691
2957
  if (parameterMappings.windSpeed) {
2692
- windSpeed = collectionData.windSpeedArray[index];
2958
+ windSpeed = getWindSpeedInKts(collectionData.windSpeedArray[index], windSpeedUnit);
2693
2959
  } else if (parameterMappings.windU && parameterMappings.windV) {
2694
2960
  const windU = collectionData.windUArray[index];
2695
2961
  const windV = collectionData.windVArray[index];
2696
- windSpeed = Math.sqrt(windU ** 2 + windV ** 2);
2962
+ windSpeed = getWindSpeedInKts(Math.sqrt(windU ** 2 + windV ** 2), windSpeedUnit);
2697
2963
  }
2698
2964
  let windDirection = NaN;
2699
2965
  if (parameterMappings.windDirection) {
@@ -2802,7 +3068,7 @@ const sortSoundingFeatures = data => {
2802
3068
  return featureB.properties.pressure - featureA.properties.pressure;
2803
3069
  });
2804
3070
  };
2805
- const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, verticalRange) => {
3071
+ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, windSpeedUnit, verticalRange) => {
2806
3072
  const parameters = getParameterMappings(parameterMappings);
2807
3073
  if (typeof location === 'string') {
2808
3074
  // LOCATION call
@@ -2815,7 +3081,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
2815
3081
  }
2816
3082
  try {
2817
3083
  const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
2818
- const parsedData = parseCoverageCollection(rawData, parameterMappings);
3084
+ const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
2819
3085
  return sortSoundingFeatures(parsedData);
2820
3086
  } catch (_err) {
2821
3087
  throw new Error('Error fetching EDR data collection by location');
@@ -2833,7 +3099,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
2833
3099
  }
2834
3100
  try {
2835
3101
  const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
2836
- const parsedData = parseCoverageCollection(rawData, parameterMappings);
3102
+ const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
2837
3103
  return sortSoundingFeatures(parsedData);
2838
3104
  } catch (_err) {
2839
3105
  throw new Error('Error fetching EDR data collection by position');
@@ -2868,6 +3134,7 @@ const useMultipleSources = (sources, locations, datetime) => {
2868
3134
  url,
2869
3135
  instanceId,
2870
3136
  parameterMappings,
3137
+ windSpeedUnit,
2871
3138
  verticalRange
2872
3139
  }, index) => {
2873
3140
  // Find the best matching timestep for this source
@@ -2892,7 +3159,7 @@ const useMultipleSources = (sources, locations, datetime) => {
2892
3159
  dateToUse = nearestTimestep;
2893
3160
  }
2894
3161
  }
2895
- const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings, verticalRange || '');
3162
+ const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings, windSpeedUnit || 'ms', verticalRange || '');
2896
3163
  return queryParams;
2897
3164
  });
2898
3165
  }, [datetime, locations, sources, timesteps]);
@@ -3138,7 +3405,7 @@ const initialSoundingsState = {
3138
3405
  selectedTime: new Date().toISOString(),
3139
3406
  diagramType: 'SKEWT',
3140
3407
  soundingPanels: {
3141
- order: ['soundings', 'windbarbs'],
3408
+ order: ['soundings', 'windbarbs', 'hodograph'],
3142
3409
  entities: {
3143
3410
  soundings: {
3144
3411
  id: 'soundings',
@@ -3149,6 +3416,11 @@ const initialSoundingsState = {
3149
3416
  id: 'windbarbs',
3150
3417
  name: 'Wind Barbs',
3151
3418
  isDisplayed: true
3419
+ },
3420
+ hodograph: {
3421
+ id: 'hodograph',
3422
+ name: 'Hodograph',
3423
+ isDisplayed: true
3152
3424
  }
3153
3425
  }
3154
3426
  },
@@ -3349,6 +3621,7 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
3349
3621
  instanceId: value.instanceId,
3350
3622
  verticalRange: value.verticalRange,
3351
3623
  parameterMappings: value.parameterMappings,
3624
+ windSpeedUnit: value.windSpeedUnit ?? 'ms',
3352
3625
  name: value.name ?? ''
3353
3626
  });
3354
3627
  }
@@ -4088,7 +4361,8 @@ const SoundingsConnect = ({
4088
4361
  const orderedIds = newOrder.map(collection => collection.id);
4089
4362
  dispatch(soundingsActions.changeSoundingPanelOrder({
4090
4363
  panelId: viewId,
4091
- newOrderOfIds: orderedIds
4364
+ newOrderOfIds: orderedIds,
4365
+ origin: SoundingsActionOrigin.soundingsComponent
4092
4366
  }));
4093
4367
  };
4094
4368
  return jsxs(Fragment, {
@@ -4360,6 +4634,7 @@ const SoundingsSourceDialogConnect = ({
4360
4634
  url: collection.url,
4361
4635
  isChecked: currentSelection?.isChecked || false,
4362
4636
  parameterMappings: collection.parameterMappings,
4637
+ windSpeedUnit: collection.windSpeedUnit,
4363
4638
  ...(collection.name && collection.name !== '' ? {
4364
4639
  name: collection.name
4365
4640
  } : {})
@@ -4589,28 +4864,6 @@ const LocationsMenuConnect = ({
4589
4864
  });
4590
4865
  };
4591
4866
 
4592
- var SoundingManagerDummyPreset = {
4593
- order: [
4594
- "soundings",
4595
- "windbarbs"
4596
- ],
4597
- entities: {
4598
- soundings: {
4599
- id: "soundings",
4600
- name: "Soundings",
4601
- isDisplayed: true
4602
- },
4603
- windbarbs: {
4604
- id: "windbarbs",
4605
- name: "Wind Barbs",
4606
- isDisplayed: true
4607
- }
4608
- }
4609
- };
4610
- var preset = {
4611
- SoundingManagerDummyPreset: SoundingManagerDummyPreset
4612
- };
4613
-
4614
4867
  const SoundingManagerListItem = ({
4615
4868
  id,
4616
4869
  name,
@@ -4723,24 +4976,20 @@ const SoundingsManagerConnect = () => {
4723
4976
  } = useSetupDialog(uiTypes.DialogTypes.SoundingsManager);
4724
4977
  const dispatch = useDispatch();
4725
4978
  const panelId = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsManager));
4726
- React.useEffect(() => {
4727
- const data = preset.SoundingManagerDummyPreset;
4728
- dispatch(soundingsActions.addSoundingPanelsFromPreset({
4729
- panelId,
4730
- panels: data
4731
- }));
4732
- }, [dispatch, panelId]);
4733
4979
  const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store, panelId));
4734
4980
  const changeOrder = newOrder => {
4735
4981
  const orderedIds = newOrder.map(collection => collection.id);
4736
4982
  dispatch(soundingsActions.changeSoundingPanelOrder({
4737
4983
  panelId,
4738
- newOrderOfIds: orderedIds
4984
+ newOrderOfIds: orderedIds,
4985
+ origin: 'soundingsManager'
4739
4986
  }));
4740
4987
  };
4741
4988
  const toggleVisibility = id => {
4742
4989
  dispatch(soundingsActions.toggleSoundingPanelVisibility({
4743
- id
4990
+ id,
4991
+ panelId,
4992
+ origin: 'soundingsManager'
4744
4993
  }));
4745
4994
  };
4746
4995
  return jsx(SoundingsManager, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeoweb/soundings",
3
- "version": "15.2.0",
3
+ "version": "15.3.0",
4
4
  "description": "GeoWeb soundings library for the opengeoweb project",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -8,24 +8,24 @@
8
8
  "url": "git@gitlab.com:opengeoweb/opengeoweb.git"
9
9
  },
10
10
  "dependencies": {
11
- "@opengeoweb/api": "15.2.0",
11
+ "@opengeoweb/api": "15.3.0",
12
12
  "@tanstack/react-query": "^5.85.5",
13
13
  "react": "^18.3.1",
14
14
  "d3": "^7.9.0",
15
15
  "react-i18next": "^15.1.1",
16
16
  "i18next": "^25.0.1",
17
- "@opengeoweb/shared": "15.2.0",
17
+ "@opengeoweb/shared": "15.3.0",
18
18
  "@mui/material": "^7.0.1",
19
- "@opengeoweb/store": "15.2.0",
19
+ "@opengeoweb/store": "15.3.0",
20
20
  "react-redux": "^9.2.0",
21
- "@opengeoweb/theme": "15.2.0",
21
+ "@opengeoweb/theme": "15.3.0",
22
22
  "@reduxjs/toolkit": "^2.6.1",
23
- "@opengeoweb/time-slider": "15.2.0",
23
+ "@opengeoweb/time-slider": "15.3.0",
24
24
  "react-window": "^2.2.6",
25
- "@opengeoweb/webmap": "15.2.0",
25
+ "@opengeoweb/webmap": "15.3.0",
26
26
  "lodash": "^4.17.21",
27
27
  "@types/geojson": "^7946.0.14",
28
- "@opengeoweb/webmap-react": "15.2.0",
28
+ "@opengeoweb/webmap-react": "15.3.0",
29
29
  "react-resizable-layout": "^0.7.3",
30
30
  "react-sortablejs": "^6.1.4"
31
31
  },
@@ -1,6 +1,7 @@
1
1
  import type { Coverage, CoverageCollection, VerticalProfileDomain } from '@opengeoweb/shared';
2
2
  import type { CollectionDetails, SoundingFeature, SoundingsInstance } from './types';
3
- export declare function parseCoverageCollection(response: CoverageCollection<VerticalProfileDomain> | Coverage<VerticalProfileDomain>, parameterMappings: Record<string, string>): SoundingFeature[];
3
+ export declare const getWindSpeedInKts: (windSpeed: number, windSpeedUnit: string) => number;
4
+ export declare function parseCoverageCollection(response: CoverageCollection<VerticalProfileDomain> | Coverage<VerticalProfileDomain>, parameterMappings: Record<string, string>, windSpeedUnit: string): SoundingFeature[];
4
5
  /**
5
6
  * Parse duration with repeats
6
7
  * @param dateString e.g. 'R67/2025-04-22T09:00:00Z/PT60M'
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { SoundingFeature } from '../../api/edr-api/types';
3
+ export interface HodographProps {
4
+ data: SoundingFeature[][];
5
+ }
6
+ export declare const Hodograph: React.FC<HodographProps>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-webpack5';
2
+ import { HodographCollection } from './HodographCollection';
3
+ declare const meta: Meta<typeof HodographCollection>;
4
+ export default meta;
5
+ type Story = StoryObj<typeof HodographCollection>;
6
+ export declare const HodographStory: Story;
7
+ export declare const HodographStoryDark: Story;
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { SoundingFeature } from '../../api/edr-api/types';
3
+ export interface HodographCollectionProps {
4
+ data: SoundingFeature[][];
5
+ }
6
+ export declare const HodographCollection: React.FC<HodographCollectionProps>;
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const HodographLegend: () => React.ReactElement;
@@ -0,0 +1,53 @@
1
+ import * as d3 from 'd3';
2
+ import type { ScalesType } from '../Soundings/types';
3
+ interface InitializeSvg {
4
+ svgRef: React.MutableRefObject<SVGSVGElement | null>;
5
+ widthHeight: number;
6
+ }
7
+ export declare const initializeSvg: ({ svgRef, widthHeight, }: InitializeSvg) => d3.Selection<SVGSVGElement | null, unknown, null, undefined>;
8
+ export declare const createScales: (widthHeight: number) => [ScalesType, ScalesType];
9
+ interface DrawAxis {
10
+ svg: d3.Selection<SVGSVGElement, unknown, HTMLElement, undefined>;
11
+ scaleRadius: ScalesType;
12
+ scaleAngle: ScalesType;
13
+ widthHeight: number;
14
+ }
15
+ export declare const drawAxes: ({ svg, scaleRadius, scaleAngle, widthHeight, }: DrawAxis) => void;
16
+ export declare const filterDomainRangeForHodograph: (pressureDomain: number[], parameterDomain: [number, number][]) => [number[], [number, number][]];
17
+ type PathSegments = [
18
+ [
19
+ number,
20
+ number
21
+ ][],
22
+ [
23
+ number,
24
+ number
25
+ ][],
26
+ [
27
+ number,
28
+ number
29
+ ][],
30
+ [
31
+ number,
32
+ number
33
+ ][],
34
+ [
35
+ number,
36
+ number
37
+ ][]
38
+ ];
39
+ export declare const altitudeBarometricFromPressure: (pressure: number) => number;
40
+ export declare const generateSegmentsBasedOnLevel: (pressure: number[], windValues: [number, number][]) => PathSegments;
41
+ export declare const HODOGRAPH_COLORS: string[];
42
+ export declare const getColorForSegment: (index: number) => string;
43
+ interface PlotHodograph {
44
+ windValues: [number, number][];
45
+ pressureValues: number[];
46
+ scaleAngle: ScalesType;
47
+ scaleRadius: ScalesType;
48
+ widthHeight: number;
49
+ svg: d3.Selection<SVGSVGElement, unknown, HTMLElement, undefined>;
50
+ index: number;
51
+ }
52
+ export declare const plotHodograph: ({ windValues, pressureValues, scaleAngle, scaleRadius, widthHeight, svg, index, }: PlotHodograph) => void;
53
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -10,6 +10,7 @@ export interface SourceType {
10
10
  instanceId?: string;
11
11
  verticalRange?: string;
12
12
  parameterMappings?: Record<string, string>;
13
+ windSpeedUnit?: string;
13
14
  name?: string;
14
15
  }
15
16
  export interface SoundingsPayload {