@opengeoweb/soundings 15.1.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/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';
@@ -17,8 +17,8 @@ import { timesliderTranslations, TIME_SLIDER_NAMESPACE, BackwardForwardStepButto
17
17
  import { useResizable } from 'react-resizable-layout';
18
18
  import { useQueries } from '@tanstack/react-query';
19
19
  import { getHeaders, handleResponse } from '@opengeoweb/api';
20
- import { createSlice, createSelector, combineReducers, configureStore } from '@reduxjs/toolkit';
21
- import { FixedSizeList } from 'react-window';
20
+ import { createSlice, createAction, createSelector, combineReducers, configureStore } from '@reduxjs/toolkit';
21
+ import { List as List$1 } from 'react-window';
22
22
 
23
23
  var en = {
24
24
  sources: "Sources",
@@ -31,6 +31,7 @@ var en = {
31
31
  "diagram-SKEWT": "Skew-T",
32
32
  "diagram-BIJVOET": "Bijvoet",
33
33
  "diagram-EMAGRAM": "Emagram",
34
+ "coming-soon": "Coming soon",
34
35
  "soundings-toggle-location-markers-label": "Markers",
35
36
  "soundings-toggle-location-markers-tooltip": "Show/hide location markers"
36
37
  };
@@ -45,6 +46,7 @@ var no = {
45
46
  "diagram-SKEWT": "Skew-T",
46
47
  "diagram-BIJVOET": "Bijvoet",
47
48
  "diagram-EMAGRAM": "Emagram",
49
+ "coming-soon": "Kommer snart",
48
50
  "soundings-toggle-location-markers-label": "ikke oversatt",
49
51
  "soundings-toggle-location-markers-tooltip": "ikke oversatt"
50
52
  };
@@ -59,6 +61,7 @@ var nl = {
59
61
  "diagram-SKEWT": "Skew-T",
60
62
  "diagram-BIJVOET": "Bijvoet",
61
63
  "diagram-EMAGRAM": "Emagram",
64
+ "coming-soon": "Binnenkort beschikbaar",
62
65
  "soundings-toggle-location-markers-label": "Kaart locaties",
63
66
  "soundings-toggle-location-markers-tooltip": "Toon/Verberg locatie punten"
64
67
  };
@@ -73,6 +76,7 @@ var fi = {
73
76
  "diagram-SKEWT": "Skew-T",
74
77
  "diagram-BIJVOET": "Bijvoet",
75
78
  "diagram-EMAGRAM": "Emagram",
79
+ "coming-soon": "Tulossa pian",
76
80
  "soundings-toggle-location-markers-label": "Merkit",
77
81
  "soundings-toggle-location-markers-tooltip": "Näytä/piilota kohdemerkit"
78
82
  };
@@ -322,7 +326,7 @@ const getDiagramName = (t, diagramType) => {
322
326
  }
323
327
  };
324
328
  // vfx code
325
- const initializeSvg = ({
329
+ const initializeSvg$1 = ({
326
330
  svgRef,
327
331
  width,
328
332
  height,
@@ -341,7 +345,7 @@ const initializeSvg = ({
341
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)`);
342
346
  return svg;
343
347
  };
344
- const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
348
+ const createScales$1 = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
345
349
  if (diagramType === 'BIJVOET') {
346
350
  // Note, for Bijvoet, the x-axis is NOT temperature but theta_es
347
351
  // Both axis aren't linear nor logarithmic, hence we need an interpolation function to determine (x,y) based on (theta_es,p)
@@ -354,7 +358,7 @@ const createScales = (diagramType, width, height, minPressure, maxPressure, minT
354
358
  const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
355
359
  return [xScale, yScale];
356
360
  };
357
- const drawAxes = ({
361
+ const drawAxes$1 = ({
358
362
  svg,
359
363
  xScale,
360
364
  yScale,
@@ -693,7 +697,7 @@ const drawSoundingsTooltip = ({
693
697
  // Wind
694
698
  windRefd3?.style('display', 'block').style('left', `${width + margin.left + margin.right}px`).style('top', `${pressureY}px`).html(() => {
695
699
  const windSpeedStrings = interpolatedData.map((dataSet, index) => {
696
- 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);
697
701
  });
698
702
  const windDirStrings = interpolatedData.map((dataSet, index) => {
699
703
  return getParamHtml(dataSet.hoveredWindDirection, index, '°', index === interpolatedData.length - 1, dataSet.color, index === interpolatedData.length - 1);
@@ -925,7 +929,7 @@ const drawIsothermsBijvoet = ({
925
929
  const pathString = lineGenerator(points);
926
930
  const strID = `${temp}-isotherm-BIJVOET`;
927
931
  svg.append('path').attr('id', strID).attr('d', pathString).style('fill', 'none').style('stroke-width', temp % 10 === 0 ? 2 : 1).style('stroke', temp === 0 ? '#D27D2D' : 'orange').style('stroke-opacity', 0.5).attr('clip-path', `url(#clip-${d3GraphId}-BIJVOETd3)`);
928
- if (temp % 10 === 0 && points[0] && points[0][1] === height) {
932
+ if (temp % 10 === 0 && points[0]?.[1] === height) {
929
933
  svg.append('text').text(temp).attr('text-anchor', 'middle').attr('x', `${points[0][0]}px`).attr('y', `${height + 12}px`).attr('fill', temp === 0 ? '#D27D2D' : 'orange').attr('font-size', '10px');
930
934
  }
931
935
  }
@@ -1227,7 +1231,7 @@ const Soundings = ({
1227
1231
  if (!svgRef.current) {
1228
1232
  return;
1229
1233
  }
1230
- const svg = initializeSvg({
1234
+ const svg = initializeSvg$1({
1231
1235
  svgRef,
1232
1236
  width,
1233
1237
  height,
@@ -1241,9 +1245,9 @@ const Soundings = ({
1241
1245
  widthRef.current = width;
1242
1246
  const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
1243
1247
  const maxT = maxTemperature;
1244
- const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1248
+ const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1245
1249
  yScaleRef.current = yScale;
1246
- drawAxes({
1250
+ drawAxes$1({
1247
1251
  svg,
1248
1252
  xScale,
1249
1253
  yScale,
@@ -1330,7 +1334,7 @@ const Soundings = ({
1330
1334
  const dewpointRefd3 = d3.select(dewpointRef.current);
1331
1335
  const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
1332
1336
  const maxT = maxTemperature;
1333
- const [, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1337
+ const [, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1334
1338
  drawSoundingsTooltip({
1335
1339
  event: synthetic,
1336
1340
  yScale,
@@ -1734,7 +1738,6 @@ const windSpeedRanges = [{
1734
1738
  * Copyright 2025 - Finnish Meteorological Institute (FMI)
1735
1739
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1736
1740
  * */
1737
- const metersPerSecondToKnots = metersPerSecond => metersPerSecond * 1.94384;
1738
1741
  const extendIsoBar = (path, horizontalBarbMargin) => {
1739
1742
  const match = /^M([\d.-]+),([\d.-]+)\s*L([\d.-]+),([\d.-]+)/.exec(path);
1740
1743
  if (match) {
@@ -1842,11 +1845,10 @@ const removeItemsExponential = array => {
1842
1845
  return array.filter((_, index) => indicesToKeep.includes(index));
1843
1846
  };
1844
1847
  const getWindSymbol = value => {
1845
- const valueKts = metersPerSecondToKnots(value);
1846
1848
  const matchedRange = windSpeedRanges.find(({
1847
1849
  min,
1848
1850
  max
1849
- }) => valueKts >= min && valueKts < max);
1851
+ }) => value >= min && value < max);
1850
1852
  // Out of range
1851
1853
  if (!matchedRange) {
1852
1854
  return null;
@@ -1925,7 +1927,7 @@ const WindBarbs = ({
1925
1927
  }
1926
1928
  const root = d3.select(svgRef.current);
1927
1929
  root.selectAll('*').remove();
1928
- const svg = initializeSvg({
1930
+ const svg = initializeSvg$1({
1929
1931
  svgRef,
1930
1932
  width,
1931
1933
  height,
@@ -1943,7 +1945,7 @@ const WindBarbs = ({
1943
1945
  widthRef.current = width;
1944
1946
  const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
1945
1947
  const maxT = maxTemperature;
1946
- const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1948
+ const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
1947
1949
  yScaleRef.current = yScale;
1948
1950
  drawIsobars({
1949
1951
  diagramType,
@@ -2036,6 +2038,257 @@ const WindBarbs = ({
2036
2038
  });
2037
2039
  };
2038
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
+
2039
2292
  const ACCORDION_MIN_WIDTH = 24;
2040
2293
  const SoundingsAccordion = React.forwardRef(({
2041
2294
  name,
@@ -2047,7 +2300,8 @@ const SoundingsAccordion = React.forwardRef(({
2047
2300
  children,
2048
2301
  setSoundingsIsClosed,
2049
2302
  setPreviousHeight,
2050
- size
2303
+ size,
2304
+ isDisplayed
2051
2305
  }, aspectRatioRef) => {
2052
2306
  const containerRef = React.useRef(null);
2053
2307
  const widthRef = React.useRef(null);
@@ -2164,6 +2418,7 @@ const SoundingsAccordion = React.forwardRef(({
2164
2418
  };
2165
2419
  }, [autoSizing, minWidth, setContainerWidth]);
2166
2420
  const cardStyleWhenAuto = {
2421
+ display: isDisplayed ? 'block' : 'none',
2167
2422
  width: 'auto',
2168
2423
  flex: '1 1 100%',
2169
2424
  minWidth: 0,
@@ -2171,12 +2426,14 @@ const SoundingsAccordion = React.forwardRef(({
2171
2426
  boxSizing: 'border-box'
2172
2427
  };
2173
2428
  const cardStyleWhenLocked = {
2429
+ display: isDisplayed ? 'block' : 'none',
2174
2430
  width: containerWidth,
2175
2431
  flex: '0 1 auto',
2176
2432
  minWidth,
2177
2433
  maxWidth: maxWidth || '100%',
2178
2434
  boxSizing: 'border-box'
2179
2435
  };
2436
+ //
2180
2437
  return jsx(Card, {
2181
2438
  className: "sortable-item",
2182
2439
  "data-autosizing": autoSizing ? 'true' : 'false',
@@ -2291,7 +2548,7 @@ const SortableAccordions = ({
2291
2548
  }) => {
2292
2549
  const [soundingsIsClosed, setSoundingsIsClosed] = React.useState(false);
2293
2550
  const [previousHeight, setPreviousHeight] = React.useState(size);
2294
- const createPanel = React.useCallback(id => {
2551
+ const createPanel = React.useCallback((id, isDisplayed) => {
2295
2552
  const normalizedId = id.toLowerCase().replace(/\s+/g, '');
2296
2553
  switch (normalizedId) {
2297
2554
  case 'soundings':
@@ -2301,6 +2558,7 @@ const SortableAccordions = ({
2301
2558
  diagramType: chosenDiagramType,
2302
2559
  setDiagramType: setChosenDiagramType
2303
2560
  }),
2561
+ isDisplayed: isDisplayed,
2304
2562
  name: "Soundings",
2305
2563
  ref: viewRef,
2306
2564
  setPreviousHeight: setPreviousHeight,
@@ -2324,6 +2582,7 @@ const SortableAccordions = ({
2324
2582
  });
2325
2583
  case 'windbarbs':
2326
2584
  return jsx(SoundingsAccordion, {
2585
+ isDisplayed: isDisplayed,
2327
2586
  maxWidth: 200,
2328
2587
  name: "Wind Barbs",
2329
2588
  children: jsx(WindBarbs, {
@@ -2343,6 +2602,14 @@ const SortableAccordions = ({
2343
2602
  }
2344
2603
  })
2345
2604
  });
2605
+ case 'hodograph':
2606
+ return jsx(SoundingsAccordion, {
2607
+ maxWidth: 500,
2608
+ name: "Hodograph",
2609
+ children: jsx(HodographCollection, {
2610
+ data: data
2611
+ })
2612
+ });
2346
2613
  default:
2347
2614
  return null;
2348
2615
  }
@@ -2378,16 +2645,18 @@ const SortableAccordions = ({
2378
2645
  width: '100%'
2379
2646
  },
2380
2647
  tag: "div",
2381
- children: soundingPanelsInOrder.filter(entry => entry.isDisplayed).flatMap(({
2382
- id
2648
+ children: soundingPanelsInOrder.flatMap(({
2649
+ id,
2650
+ isDisplayed
2383
2651
  }) => {
2384
- const panel = createPanel(id);
2652
+ const panel = createPanel(id, isDisplayed);
2385
2653
  if (!panel) {
2386
2654
  return [];
2387
2655
  }
2388
2656
  return panel ? jsx(React.Fragment, {
2389
2657
  children: React.cloneElement(panel, {
2390
2658
  accordionId: id,
2659
+ isDisplayed,
2391
2660
  handleButtonComponent: jsx(IconButton, {
2392
2661
  "aria-label": "drag-handle",
2393
2662
  className: "drag-handle",
@@ -2546,6 +2815,7 @@ const aConstant = 17.67;
2546
2815
  const bConstant = 243.5; // Constants for water, b in Celsius
2547
2816
  const gConstant = 461.5; // Gas constant ratio between water vapour and air [J/(K*kg)]
2548
2817
  const hConstant = 2.5e6; // Latent heat for water vaporization [J/kg]
2818
+ const ktsConstant = 1.94384;
2549
2819
  // Formula to use when dewpoint is not available
2550
2820
  // Known as Magnus Formula
2551
2821
  // - Calculate vapor pressure
@@ -2570,8 +2840,15 @@ relHumidity) {
2570
2840
  const dewpoint = tKelvin / (1 - tKelvin * Math.log(relHumidity * 0.01) * gConstant / hConstant);
2571
2841
  return dewpoint;
2572
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
+ };
2573
2850
  // Parsing and conversion function
2574
- function parseCoverageCollection(response, parameterMappings) {
2851
+ function parseCoverageCollection(response, parameterMappings, windSpeedUnit) {
2575
2852
  const features = [];
2576
2853
  const pressureArray = [];
2577
2854
  const tempArray = [];
@@ -2675,13 +2952,14 @@ function parseCoverageCollection(response, parameterMappings) {
2675
2952
  dewpoint = dewpointFromRelativeHumidity(tempArray[index], relativeHumidity);
2676
2953
  }
2677
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
2678
2956
  let windSpeed = NaN;
2679
2957
  if (parameterMappings.windSpeed) {
2680
- windSpeed = collectionData.windSpeedArray[index];
2958
+ windSpeed = getWindSpeedInKts(collectionData.windSpeedArray[index], windSpeedUnit);
2681
2959
  } else if (parameterMappings.windU && parameterMappings.windV) {
2682
2960
  const windU = collectionData.windUArray[index];
2683
2961
  const windV = collectionData.windVArray[index];
2684
- windSpeed = Math.sqrt(windU ** 2 + windV ** 2);
2962
+ windSpeed = getWindSpeedInKts(Math.sqrt(windU ** 2 + windV ** 2), windSpeedUnit);
2685
2963
  }
2686
2964
  let windDirection = NaN;
2687
2965
  if (parameterMappings.windDirection) {
@@ -2790,7 +3068,7 @@ const sortSoundingFeatures = data => {
2790
3068
  return featureB.properties.pressure - featureA.properties.pressure;
2791
3069
  });
2792
3070
  };
2793
- const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, verticalRange) => {
3071
+ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, windSpeedUnit, verticalRange) => {
2794
3072
  const parameters = getParameterMappings(parameterMappings);
2795
3073
  if (typeof location === 'string') {
2796
3074
  // LOCATION call
@@ -2803,7 +3081,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
2803
3081
  }
2804
3082
  try {
2805
3083
  const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
2806
- const parsedData = parseCoverageCollection(rawData, parameterMappings);
3084
+ const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
2807
3085
  return sortSoundingFeatures(parsedData);
2808
3086
  } catch (_err) {
2809
3087
  throw new Error('Error fetching EDR data collection by location');
@@ -2821,7 +3099,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
2821
3099
  }
2822
3100
  try {
2823
3101
  const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
2824
- const parsedData = parseCoverageCollection(rawData, parameterMappings);
3102
+ const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
2825
3103
  return sortSoundingFeatures(parsedData);
2826
3104
  } catch (_err) {
2827
3105
  throw new Error('Error fetching EDR data collection by position');
@@ -2856,6 +3134,7 @@ const useMultipleSources = (sources, locations, datetime) => {
2856
3134
  url,
2857
3135
  instanceId,
2858
3136
  parameterMappings,
3137
+ windSpeedUnit,
2859
3138
  verticalRange
2860
3139
  }, index) => {
2861
3140
  // Find the best matching timestep for this source
@@ -2880,7 +3159,7 @@ const useMultipleSources = (sources, locations, datetime) => {
2880
3159
  dateToUse = nearestTimestep;
2881
3160
  }
2882
3161
  }
2883
- 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 || '');
2884
3163
  return queryParams;
2885
3164
  });
2886
3165
  }, [datetime, locations, sources, timesteps]);
@@ -3061,6 +3340,48 @@ const useGetCollectionLocations = (collections, enabled) => {
3061
3340
  });
3062
3341
  };
3063
3342
 
3343
+ /* *
3344
+ * Licensed under the Apache License, Version 2.0 (the "License");
3345
+ * you may not use this file except in compliance with the License.
3346
+ * You may obtain a copy of the License at
3347
+ *
3348
+ * http://www.apache.org/licenses/LICENSE-2.0
3349
+ *
3350
+ * Unless required by applicable law or agreed to in writing, software
3351
+ * distributed under the License is distributed on an "AS IS" BASIS,
3352
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3353
+ * See the License for the specific language governing permissions and
3354
+ * limitations under the License.
3355
+ *
3356
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
3357
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
3358
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
3359
+ * */
3360
+ const emptyPanelIds = [];
3361
+ const toStringArray = value => {
3362
+ if (!Array.isArray(value)) {
3363
+ return emptyPanelIds;
3364
+ }
3365
+ return value.filter(item => typeof item === 'string');
3366
+ };
3367
+ const getRegisteredPanelIds = state => {
3368
+ if (!state) {
3369
+ return emptyPanelIds;
3370
+ }
3371
+ const registeredPanelIds = toStringArray(state.registeredPanelIds);
3372
+ if (registeredPanelIds.length > 0) {
3373
+ return registeredPanelIds;
3374
+ }
3375
+ return Object.keys(state.byId);
3376
+ };
3377
+ const isRegisteredPanelId = (state, panelId) => {
3378
+ if (!panelId) {
3379
+ return false;
3380
+ }
3381
+ return getRegisteredPanelIds(state).includes(panelId);
3382
+ };
3383
+ const getPanelIdFromAction = action => action.payload?.panelId;
3384
+
3064
3385
  /* *
3065
3386
  * Licensed under the Apache License, Version 2.0 (the "License");
3066
3387
  * you may not use this file except in compliance with the License.
@@ -3084,7 +3405,7 @@ const initialSoundingsState = {
3084
3405
  selectedTime: new Date().toISOString(),
3085
3406
  diagramType: 'SKEWT',
3086
3407
  soundingPanels: {
3087
- order: ['soundings', 'windbarbs'],
3408
+ order: ['soundings', 'windbarbs', 'hodograph'],
3088
3409
  entities: {
3089
3410
  soundings: {
3090
3411
  id: 'soundings',
@@ -3095,17 +3416,31 @@ const initialSoundingsState = {
3095
3416
  id: 'windbarbs',
3096
3417
  name: 'Wind Barbs',
3097
3418
  isDisplayed: true
3419
+ },
3420
+ hodograph: {
3421
+ id: 'hodograph',
3422
+ name: 'Hodograph',
3423
+ isDisplayed: true
3098
3424
  }
3099
3425
  }
3100
3426
  },
3101
3427
  shouldHideLocationMarkers: false
3102
3428
  };
3429
+ const initialState = {
3430
+ byId: {},
3431
+ registeredPanelIds: [],
3432
+ soundingsCollections: []
3433
+ };
3103
3434
  const slice = createSlice({
3104
3435
  initialState: initialSoundingsState,
3105
3436
  name: 'soundings',
3106
3437
  reducers: {
3438
+ registerSoundingsPanel: (draft, _payloadAction) => {
3439
+ },
3107
3440
  registerSoundingsPreset: (draft, payloadAction) => {
3108
- const preset = payloadAction.payload;
3441
+ const {
3442
+ preset
3443
+ } = payloadAction.payload;
3109
3444
  if (preset) {
3110
3445
  draft.selectedSources = preset.selectedSources ?? {};
3111
3446
  draft.selectedLocations = preset.selectedLocations ?? {};
@@ -3151,7 +3486,7 @@ const slice = createSlice({
3151
3486
  const {
3152
3487
  order,
3153
3488
  entities
3154
- } = payloadAction.payload;
3489
+ } = payloadAction.payload.panels;
3155
3490
  draft.soundingPanels.order = order;
3156
3491
  draft.soundingPanels.entities = entities;
3157
3492
  },
@@ -3160,13 +3495,86 @@ const slice = createSlice({
3160
3495
  newOrderOfIds
3161
3496
  } = payloadAction.payload;
3162
3497
  draft.soundingPanels.order = newOrderOfIds;
3498
+ },
3499
+ toggleSoundingPanelVisibility: (draft, payloadAction) => {
3500
+ const {
3501
+ id
3502
+ } = payloadAction.payload;
3503
+ const panel = draft.soundingPanels.entities[id];
3504
+ if (panel) {
3505
+ panel.isDisplayed = !panel.isDisplayed;
3506
+ }
3163
3507
  }
3164
3508
  }
3165
3509
  });
3166
- const {
3167
- reducer: soundingsReducer,
3168
- actions: soundingsActions
3169
- } = slice;
3510
+ const setSoundingsCollections = createAction('soundings/setCollections');
3511
+ const soundingsActions = {
3512
+ ...slice.actions,
3513
+ setSoundingsCollections
3514
+ };
3515
+ const shouldHandlePanelAction = (state, action) => {
3516
+ return isRegisteredPanelId(state, getPanelIdFromAction(action));
3517
+ };
3518
+ const normalizeSoundingsState = state => {
3519
+ if ('byId' in state) {
3520
+ return state;
3521
+ }
3522
+ return {
3523
+ ...initialState,
3524
+ byId: {
3525
+ ...initialState.byId,
3526
+ default: state
3527
+ },
3528
+ registeredPanelIds: ['default']
3529
+ };
3530
+ };
3531
+ const soundingsReducer = (state, action) => {
3532
+ const normalizedState = normalizeSoundingsState(state ?? initialState);
3533
+ if (action.type === setSoundingsCollections.type) {
3534
+ const collections = action.payload;
3535
+ if (collections === normalizedState.soundingsCollections) {
3536
+ return normalizedState;
3537
+ }
3538
+ return {
3539
+ ...normalizedState,
3540
+ soundingsCollections: collections
3541
+ };
3542
+ }
3543
+ if (action.type === slice.actions.registerSoundingsPanel.type) {
3544
+ const {
3545
+ panelId
3546
+ } = action.payload;
3547
+ if (!panelId) {
3548
+ return normalizedState;
3549
+ }
3550
+ if (normalizedState.registeredPanelIds?.includes(panelId)) {
3551
+ return normalizedState;
3552
+ }
3553
+ return {
3554
+ ...normalizedState,
3555
+ registeredPanelIds: [...(normalizedState.registeredPanelIds ?? []), panelId]
3556
+ };
3557
+ }
3558
+ if (!shouldHandlePanelAction(normalizedState, action)) {
3559
+ return normalizedState;
3560
+ }
3561
+ const panelId = getPanelIdFromAction(action);
3562
+ if (!panelId) {
3563
+ return normalizedState;
3564
+ }
3565
+ const currentPanelState = normalizedState.byId[panelId];
3566
+ const nextPanelState = slice.reducer(currentPanelState ?? initialSoundingsState, action);
3567
+ if (nextPanelState === currentPanelState) {
3568
+ return normalizedState;
3569
+ }
3570
+ return {
3571
+ ...normalizedState,
3572
+ byId: {
3573
+ ...normalizedState.byId,
3574
+ [panelId]: nextPanelState
3575
+ }
3576
+ };
3577
+ };
3170
3578
 
3171
3579
  /* *
3172
3580
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -3185,7 +3593,16 @@ const {
3185
3593
  * Copyright 2025 - Finnish Meteorological Institute (FMI)
3186
3594
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
3187
3595
  * */
3188
- const getSoundingsStore = store => store?.soundings || initialSoundingsState;
3596
+ const emptySoundingsCollections = [];
3597
+ const getSoundingsStore = (store, panelId) => {
3598
+ const soundingsState = store?.soundings;
3599
+ if (!panelId || !isRegisteredPanelId(soundingsState, panelId)) {
3600
+ return initialSoundingsState;
3601
+ }
3602
+ const panelStore = soundingsState?.byId[panelId];
3603
+ return panelStore ?? initialSoundingsState;
3604
+ };
3605
+ const getSoundingsCollections = store => store?.soundings?.soundingsCollections || emptySoundingsCollections;
3189
3606
  const getSourceSelections = createSelector(getSoundingsStore, store => {
3190
3607
  return store.selectedSources;
3191
3608
  }, selectorMemoizationOptions);
@@ -3204,6 +3621,7 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
3204
3621
  instanceId: value.instanceId,
3205
3622
  verticalRange: value.verticalRange,
3206
3623
  parameterMappings: value.parameterMappings,
3624
+ windSpeedUnit: value.windSpeedUnit ?? 'ms',
3207
3625
  name: value.name ?? ''
3208
3626
  });
3209
3627
  }
@@ -3238,14 +3656,15 @@ const getSoundingsPreset = createSelector(getSoundingsStore, soundingsStore => {
3238
3656
  selectedSources: filteredSources
3239
3657
  };
3240
3658
  }, selectorMemoizationOptions);
3659
+ const EMPTY_SOUDINGS_PANELS = {
3660
+ order: [],
3661
+ entities: {}
3662
+ };
3241
3663
  const getSoundingPanelsInOrder = createSelector(getSoundingsStore, store => {
3242
3664
  const {
3243
3665
  order = [],
3244
3666
  entities = {}
3245
- } = store.soundingPanels || {
3246
- order: [],
3247
- entities: {}
3248
- };
3667
+ } = store.soundingPanels || EMPTY_SOUDINGS_PANELS;
3249
3668
  return order.map(id => entities[id]);
3250
3669
  }, selectorMemoizationOptions);
3251
3670
 
@@ -3259,6 +3678,7 @@ var selectors = /*#__PURE__*/Object.freeze({
3259
3678
  getSelectedTime: getSelectedTime,
3260
3679
  getShouldHideLocationMarkers: getShouldHideLocationMarkers,
3261
3680
  getSoundingPanelsInOrder: getSoundingPanelsInOrder,
3681
+ getSoundingsCollections: getSoundingsCollections,
3262
3682
  getSoundingsPreset: getSoundingsPreset,
3263
3683
  getSourceSelections: getSourceSelections
3264
3684
  });
@@ -3413,14 +3833,19 @@ const MenuBarConnect = ({
3413
3833
  source
3414
3834
  }) => {
3415
3835
  const dispatch = useDispatch();
3836
+ React.useEffect(() => {
3837
+ dispatch(soundingsActions.registerSoundingsPanel({
3838
+ panelId: viewId
3839
+ }));
3840
+ }, [dispatch, viewId]);
3416
3841
  const currentActiveMapIdSources = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsSources));
3417
3842
  const currentActiveMapIdLocations = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsLocations));
3418
3843
  const currentActiveMapIdsoundingsManager = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsManager));
3419
3844
  const isSourcesOpenInStore = useSelector(store => uiSelectors.getisDialogOpen(store, uiTypes.DialogTypes.SoundingsSources));
3420
3845
  const isLocationsOpenInStore = useSelector(store => uiSelectors.getisDialogOpen(store, uiTypes.DialogTypes.SoundingsLocations));
3421
3846
  const isSoundingsManagerOpenInStore = useSelector(store => uiSelectors.getisDialogOpen(store, uiTypes.DialogTypes.SoundingsManager));
3422
- const sourcesAmount = useSelector(getSelectedSourcesAmount);
3423
- const shouldHideLocationMarkers = useSelector(getShouldHideLocationMarkers);
3847
+ const sourcesAmount = useSelector(state => getSelectedSourcesAmount(state, viewId));
3848
+ const shouldHideLocationMarkers = useSelector(state => getShouldHideLocationMarkers(state, viewId));
3424
3849
  const toggleLocationMarkers = React.useCallback(shouldHide => {
3425
3850
  dispatch(soundingsActions.toggleLocationMarkers({
3426
3851
  shouldHideLocationMarkers: shouldHide,
@@ -3450,7 +3875,7 @@ const MenuBarConnect = ({
3450
3875
  const isOpen = {
3451
3876
  [MenuOptions.sources]: currentActiveMapIdSources === viewId && isSourcesOpenInStore,
3452
3877
  [MenuOptions.locations]: currentActiveMapIdLocations === viewId && isLocationsOpenInStore,
3453
- [MenuOptions.soundingsManager]: isSoundingsManagerOpenInStore
3878
+ [MenuOptions.soundingsManager]: currentActiveMapIdsoundingsManager === viewId && isSoundingsManagerOpenInStore
3454
3879
  };
3455
3880
  return jsx(SoundingsMenuBar, {
3456
3881
  handleClick: handleClick,
@@ -3600,8 +4025,8 @@ const roundToClosestTimestep = (timeToRound, availableSteps) => {
3600
4025
  const SoundingsTimeSliderConnect = ({
3601
4026
  viewId
3602
4027
  }) => {
3603
- const selectedTime = useSelector(getSelectedTime);
3604
- const sourceSelections = useSelector(getSelectedSources);
4028
+ const selectedTime = useSelector(state => getSelectedTime(state, viewId));
4029
+ const sourceSelections = useSelector(state => getSelectedSources(state, viewId));
3605
4030
  const dispatch = useDispatch();
3606
4031
  const updateSelectedTime = React.useCallback((selectedTime, isInitialCorrection = false) => {
3607
4032
  dispatch(soundingsActions.updateSelectedTime({
@@ -3673,12 +4098,18 @@ const SoundingsConnect = ({
3673
4098
  const initialLocationsRef = React.useRef({});
3674
4099
  const previousLocationsRef = React.useRef({});
3675
4100
  React.useLayoutEffect(() => {
4101
+ dispatch(soundingsActions.registerSoundingsPanel({
4102
+ panelId: viewId
4103
+ }));
3676
4104
  if (soundingsPreset && !isEqual(soundingsPreset, soundingsPresetRef.current)) {
3677
4105
  soundingsPresetRef.current = soundingsPreset;
3678
4106
  presetJustLoadedRef.current = true;
3679
4107
  initialLocationsRef.current = soundingsPreset.selectedLocations || {};
3680
4108
  previousLocationsRef.current = {};
3681
- dispatch(soundingsActions.registerSoundingsPreset(soundingsPreset));
4109
+ dispatch(soundingsActions.registerSoundingsPreset({
4110
+ panelId: viewId,
4111
+ preset: soundingsPreset
4112
+ }));
3682
4113
  setTimeout(() => {
3683
4114
  presetJustLoadedRef.current = false;
3684
4115
  initialLocationsRef.current = {};
@@ -3689,11 +4120,11 @@ const SoundingsConnect = ({
3689
4120
  previousLocationsRef.current = {};
3690
4121
  }
3691
4122
  }, [dispatch, soundingsPreset, viewId]);
3692
- const selectedTime = useSelector(getSelectedTime);
3693
- const sourceSelections = useSelector(getSelectedSources, shallowEqual);
3694
- const locationSelections = useSelector(getLocationSelections, shallowEqual);
3695
- const diagramType = useSelector(getDiagramType);
3696
- const shouldHideLocationMarkers = useSelector(getShouldHideLocationMarkers);
4123
+ const selectedTime = useSelector(state => getSelectedTime(state, viewId));
4124
+ const sourceSelections = useSelector(state => getSelectedSources(state, viewId), shallowEqual);
4125
+ const locationSelections = useSelector(state => getLocationSelections(state, viewId), shallowEqual);
4126
+ const diagramType = useSelector(state => getDiagramType(state, viewId));
4127
+ const shouldHideLocationMarkers = useSelector(state => getShouldHideLocationMarkers(state, viewId));
3697
4128
  const setChosenDiagramType = React.useCallback(diagramType => {
3698
4129
  dispatch(soundingsActions.updateDiagramType({
3699
4130
  diagramType,
@@ -3925,11 +4356,13 @@ const SoundingsConnect = ({
3925
4356
  data,
3926
4357
  isPending
3927
4358
  } = useMultipleSources(sourceSelections, locationSelections, selectedTime);
3928
- const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store));
4359
+ const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store, viewId));
3929
4360
  const changeOrder = newOrder => {
3930
4361
  const orderedIds = newOrder.map(collection => collection.id);
3931
4362
  dispatch(soundingsActions.changeSoundingPanelOrder({
3932
- newOrderOfIds: orderedIds
4363
+ panelId: viewId,
4364
+ newOrderOfIds: orderedIds,
4365
+ origin: SoundingsActionOrigin.soundingsComponent
3933
4366
  }));
3934
4367
  };
3935
4368
  return jsxs(Fragment, {
@@ -4182,10 +4615,17 @@ const SoundingsSourceDialogConnect = ({
4182
4615
  origin: SoundingsActionOrigin.soundingsComponent
4183
4616
  }));
4184
4617
  }, [dispatch, panelId]);
4185
- const sourceSelections = useSelector(getSourceSelections);
4618
+ const soundingsCollectionsFromStore = useSelector(state => getSoundingsCollections(state));
4619
+ const collections = soundingsCollections ?? soundingsCollectionsFromStore;
4620
+ const sourceSelections = useSelector(state => getSourceSelections(state, panelId));
4186
4621
  // Set mappings for each collection
4187
4622
  React.useEffect(() => {
4188
- soundingsCollections?.forEach(collection => {
4623
+ if (soundingsCollections && soundingsCollections.length > 0) {
4624
+ dispatch(soundingsActions.setSoundingsCollections(soundingsCollections));
4625
+ }
4626
+ }, [dispatch, soundingsCollections]);
4627
+ React.useEffect(() => {
4628
+ collections?.forEach(collection => {
4189
4629
  const currentSelection = sourceSelections[collection.id];
4190
4630
  if (currentSelection?.url && currentSelection?.parameterMappings) {
4191
4631
  return;
@@ -4194,6 +4634,7 @@ const SoundingsSourceDialogConnect = ({
4194
4634
  url: collection.url,
4195
4635
  isChecked: currentSelection?.isChecked || false,
4196
4636
  parameterMappings: collection.parameterMappings,
4637
+ windSpeedUnit: collection.windSpeedUnit,
4197
4638
  ...(collection.name && collection.name !== '' ? {
4198
4639
  name: collection.name
4199
4640
  } : {})
@@ -4201,16 +4642,17 @@ const SoundingsSourceDialogConnect = ({
4201
4642
  dispatch(soundingsActions.updateSelectedSource({
4202
4643
  sourceId: collection.id,
4203
4644
  value: newValue,
4645
+ panelId,
4204
4646
  origin: SoundingsActionOrigin.soundingsManager
4205
4647
  }));
4206
4648
  });
4207
4649
  // eslint-disable-next-line react-hooks/exhaustive-deps
4208
- }, [dispatch, soundingsCollections, panelId]);
4650
+ }, [dispatch, collections, panelId]);
4209
4651
  const {
4210
4652
  data,
4211
4653
  isPending,
4212
4654
  error
4213
- } = useGetCollectionsInstances(soundingsCollections, isDialogOpen);
4655
+ } = useGetCollectionsInstances(collections, isDialogOpen);
4214
4656
  return jsx(SoundingsSourceMenu, {
4215
4657
  error: error,
4216
4658
  handleClose: onCloseDialog,
@@ -4224,6 +4666,44 @@ const SoundingsSourceDialogConnect = ({
4224
4666
  });
4225
4667
  };
4226
4668
 
4669
+ const RowComponent = ({
4670
+ index,
4671
+ style,
4672
+ service,
4673
+ isChecked,
4674
+ updateSelectedLocation
4675
+ }) => {
4676
+ const location = service.features[index];
4677
+ return jsx("div", {
4678
+ style: style,
4679
+ children: jsx(ListItem, {
4680
+ disableGutters: true,
4681
+ disablePadding: true,
4682
+ children: jsxs(ListItemButton, {
4683
+ dense: true,
4684
+ onClick: () => {
4685
+ updateSelectedLocation(service.id, isChecked(service.id, location.id) ? undefined : location.id);
4686
+ },
4687
+ children: [jsx(ListItemIcon, {
4688
+ children: jsx(Checkbox, {
4689
+ checked: isChecked(service.id, location.id),
4690
+ disableRipple: true,
4691
+ edge: "start",
4692
+ slotProps: {
4693
+ input: {
4694
+ 'aria-labelledby': location.id
4695
+ }
4696
+ },
4697
+ tabIndex: -1
4698
+ })
4699
+ }), jsx(ListItemText, {
4700
+ id: location.id,
4701
+ primary: location.properties.name
4702
+ })]
4703
+ })
4704
+ })
4705
+ }, `${service.id}-${location.id}`);
4706
+ };
4227
4707
  const LocationsMenu = ({
4228
4708
  services,
4229
4709
  selectedSourcesAmount,
@@ -4315,45 +4795,18 @@ const LocationsMenu = ({
4315
4795
  }
4316
4796
  },
4317
4797
  title: service.name,
4318
- children: jsx(FixedSizeList, {
4319
- height: 320,
4320
- itemCount: service.features.length,
4321
- itemSize: 40,
4322
- width: "100%",
4323
- children: ({
4324
- index,
4325
- style
4326
- }) => {
4327
- const location = service.features[index];
4328
- return jsx("div", {
4329
- style: style,
4330
- children: jsx(ListItem, {
4331
- disableGutters: true,
4332
- disablePadding: true,
4333
- children: jsxs(ListItemButton, {
4334
- dense: true,
4335
- onClick: () => {
4336
- updateSelectedLocation(service.id, isChecked(service.id, location.id) ? undefined : location.id);
4337
- },
4338
- children: [jsx(ListItemIcon, {
4339
- children: jsx(Checkbox, {
4340
- checked: isChecked(service.id, location.id),
4341
- disableRipple: true,
4342
- edge: "start",
4343
- slotProps: {
4344
- input: {
4345
- 'aria-labelledby': location.id
4346
- }
4347
- },
4348
- tabIndex: -1
4349
- })
4350
- }), jsx(ListItemText, {
4351
- id: location.id,
4352
- primary: location.properties.name
4353
- })]
4354
- })
4355
- })
4356
- }, `${service.id}-${location.id}`);
4798
+ children: jsx(List$1, {
4799
+ rowComponent: RowComponent,
4800
+ rowCount: service.features.length,
4801
+ rowHeight: 40,
4802
+ rowProps: {
4803
+ service,
4804
+ isChecked,
4805
+ updateSelectedLocation
4806
+ },
4807
+ style: {
4808
+ height: 320,
4809
+ width: '100%'
4357
4810
  }
4358
4811
  })
4359
4812
  })
@@ -4372,14 +4825,22 @@ const LocationsMenuConnect = ({
4372
4825
  dialogOrder,
4373
4826
  setDialogOrder
4374
4827
  } = useSetupDialog(uiTypes.DialogTypes.SoundingsLocations);
4375
- const locationSelections = useSelector(getLocationSelections);
4376
- const selectedSourceIds = useSelector(getSelectedSourcesIds);
4377
- const sourcesAmount = useSelector(getSelectedSourcesAmount);
4828
+ const panelId = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsLocations));
4829
+ const locationSelections = useSelector(state => getLocationSelections(state, panelId));
4830
+ const selectedSourceIds = useSelector(state => getSelectedSourcesIds(state, panelId));
4831
+ const sourcesAmount = useSelector(state => getSelectedSourcesAmount(state, panelId));
4832
+ const soundingsCollectionsFromStore = useSelector(state => getSoundingsCollections(state));
4833
+ const collections = soundingsCollections ?? soundingsCollectionsFromStore;
4834
+ React.useEffect(() => {
4835
+ if (soundingsCollections && soundingsCollections.length > 0) {
4836
+ dispatch(soundingsActions.setSoundingsCollections(soundingsCollections));
4837
+ }
4838
+ }, [dispatch, soundingsCollections]);
4378
4839
  const {
4379
4840
  data,
4380
4841
  isPending,
4381
4842
  error
4382
- } = useGetCollectionLocations(soundingsCollections, isDialogOpen);
4843
+ } = useGetCollectionLocations(collections, isDialogOpen);
4383
4844
  const services = useMemo(() => {
4384
4845
  return data?.filter(service => selectedSourceIds.includes(service.id)) || [];
4385
4846
  }, [data, selectedSourceIds]);
@@ -4396,37 +4857,18 @@ const LocationsMenuConnect = ({
4396
4857
  updateSelectedLocation: (sourceId, locationId) => {
4397
4858
  dispatch(soundingsActions.updateSelectedLocation({
4398
4859
  sourceId,
4399
- location: locationId
4860
+ location: locationId,
4861
+ panelId
4400
4862
  }));
4401
4863
  }
4402
4864
  });
4403
4865
  };
4404
4866
 
4405
- var SoundingManagerDummyPreset = {
4406
- order: [
4407
- "soundings",
4408
- "windbarbs"
4409
- ],
4410
- entities: {
4411
- soundings: {
4412
- id: "soundings",
4413
- name: "Soundings",
4414
- isDisplayed: true
4415
- },
4416
- windbarbs: {
4417
- id: "windbarbs",
4418
- name: "Wind Barbs",
4419
- isDisplayed: true
4420
- }
4421
- }
4422
- };
4423
- var preset = {
4424
- SoundingManagerDummyPreset: SoundingManagerDummyPreset
4425
- };
4426
-
4427
4867
  const SoundingManagerListItem = ({
4868
+ id,
4428
4869
  name,
4429
- isDisplayed
4870
+ isDisplayed,
4871
+ onToggleVisibility
4430
4872
  }) => {
4431
4873
  return jsx(Card, {
4432
4874
  className: "sortable-item",
@@ -4460,7 +4902,16 @@ const SoundingManagerListItem = ({
4460
4902
  display: 'flex',
4461
4903
  alignItems: 'center'
4462
4904
  },
4463
- children: isDisplayed ? jsx(Visibility, {}) : jsx(VisibilityOff, {})
4905
+ children: jsx(IconButton, {
4906
+ "aria-label": "toggle-visibility",
4907
+ onClick: () => onToggleVisibility(id),
4908
+ size: "small",
4909
+ sx: {
4910
+ width: '24px',
4911
+ height: '24px'
4912
+ },
4913
+ children: isDisplayed ? jsx(Visibility, {}) : jsx(VisibilityOff, {})
4914
+ })
4464
4915
  }), jsxs(Box, {
4465
4916
  sx: {
4466
4917
  paddingRight: '8px',
@@ -4478,7 +4929,8 @@ const SoundingsManager = ({
4478
4929
  onMouseDown,
4479
4930
  order,
4480
4931
  soundingPanelsInOrder,
4481
- onOrderChange
4932
+ onOrderChange,
4933
+ onToggleVisibility
4482
4934
  }) => {
4483
4935
  const sortableList = React.useMemo(() => soundingPanelsInOrder.map(collection => ({
4484
4936
  ...collection
@@ -4506,8 +4958,10 @@ const SoundingsManager = ({
4506
4958
  name,
4507
4959
  isDisplayed
4508
4960
  }) => jsx(SoundingManagerListItem, {
4961
+ id: id,
4509
4962
  isDisplayed: isDisplayed,
4510
- name: name
4963
+ name: name,
4964
+ onToggleVisibility: onToggleVisibility
4511
4965
  }, id))
4512
4966
  })
4513
4967
  });
@@ -4521,15 +4975,21 @@ const SoundingsManagerConnect = () => {
4521
4975
  setDialogOrder
4522
4976
  } = useSetupDialog(uiTypes.DialogTypes.SoundingsManager);
4523
4977
  const dispatch = useDispatch();
4524
- React.useEffect(() => {
4525
- const data = preset.SoundingManagerDummyPreset;
4526
- dispatch(soundingsActions.addSoundingPanelsFromPreset(data));
4527
- }, [dispatch]);
4528
- const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store));
4978
+ const panelId = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsManager));
4979
+ const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store, panelId));
4529
4980
  const changeOrder = newOrder => {
4530
4981
  const orderedIds = newOrder.map(collection => collection.id);
4531
4982
  dispatch(soundingsActions.changeSoundingPanelOrder({
4532
- newOrderOfIds: orderedIds
4983
+ panelId,
4984
+ newOrderOfIds: orderedIds,
4985
+ origin: 'soundingsManager'
4986
+ }));
4987
+ };
4988
+ const toggleVisibility = id => {
4989
+ dispatch(soundingsActions.toggleSoundingPanelVisibility({
4990
+ id,
4991
+ panelId,
4992
+ origin: 'soundingsManager'
4533
4993
  }));
4534
4994
  };
4535
4995
  return jsx(SoundingsManager, {
@@ -4537,6 +4997,7 @@ const SoundingsManagerConnect = () => {
4537
4997
  onClose: onCloseDialog,
4538
4998
  onMouseDown: setDialogOrder,
4539
4999
  onOrderChange: changeOrder,
5000
+ onToggleVisibility: toggleVisibility,
4540
5001
  order: dialogOrder,
4541
5002
  soundingPanelsInOrder: soundingPanelsInOrder
4542
5003
  });