@opengeoweb/soundings 12.2.0 → 12.4.1

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.
Files changed (57) hide show
  1. package/index.esm.js +1781 -554
  2. package/package.json +11 -9
  3. package/src/index.d.ts +4 -0
  4. package/src/lib/api/edr-api/api.d.ts +4 -5
  5. package/src/lib/api/edr-api/hooks.d.ts +16 -3
  6. package/src/lib/api/edr-api/types.d.ts +0 -23
  7. package/src/lib/{parser/coverages.d.ts → api/edr-api/utils.d.ts} +1 -1
  8. package/src/lib/components/EDRFetchAndParse.stories.d.ts +12 -5
  9. package/src/lib/components/MenuBar/LocationsMenu.d.ts +16 -0
  10. package/src/lib/components/{SoundingsMenuBar → MenuBar}/MenuBar.d.ts +2 -1
  11. package/src/lib/components/MenuBar/MenuBar.stories.d.ts +20 -0
  12. package/src/lib/components/MenuBar/MenuBarConnect.spec.d.ts +1 -0
  13. package/src/lib/components/MenuBar/SoundingsSourceDialogConnect.spec.d.ts +1 -0
  14. package/src/lib/components/MenuBar/SoundingsSourceMenu.d.ts +16 -0
  15. package/src/lib/components/Providers/Providers.d.ts +12 -0
  16. package/src/lib/components/{Soundings.d.ts → Soundings/Soundings.d.ts} +4 -5
  17. package/src/lib/components/Soundings/Soundings.spec.d.ts +1 -0
  18. package/src/lib/components/Soundings/SoundingsConnect.d.ts +6 -0
  19. package/src/lib/components/Soundings/SoundingsConnect.integration.spec.d.ts +1 -0
  20. package/src/lib/components/Soundings/SoundingsConnect.spec.d.ts +1 -0
  21. package/src/lib/components/Soundings/SoundingsConnect.stories.d.ts +35 -0
  22. package/src/lib/components/{DrawUtils.d.ts → Soundings/drawUtils.d.ts} +7 -6
  23. package/src/lib/components/Soundings/drawUtils.spec.d.ts +1 -0
  24. package/src/lib/components/{types.d.ts → Soundings/types.d.ts} +1 -1
  25. package/src/lib/components/SoundingsDemo.stories.d.ts +15 -0
  26. package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSlider.d.ts +7 -0
  27. package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSlider.spec.d.ts +1 -0
  28. package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSlider.stories.d.ts +8 -0
  29. package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSliderConnect.d.ts +6 -0
  30. package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSliderConnect.spec.d.ts +1 -0
  31. package/src/lib/components/SoundingsTimeSlider/index.d.ts +2 -0
  32. package/src/lib/components/SoundingsTooltip/utils.d.ts +29 -0
  33. package/src/lib/components/SoundingsTooltip/utils.spec.d.ts +1 -0
  34. package/src/lib/components/StaticLines/drawIsobars.d.ts +1 -0
  35. package/src/lib/components/WindBarbs/utils.d.ts +20 -0
  36. package/src/lib/components/WindBarbs/utils.spec.d.ts +1 -0
  37. package/src/lib/components/WindBarbs/windsymbols.d.ts +5 -0
  38. package/src/lib/components/index.d.ts +3 -3
  39. package/src/lib/store/reducer.d.ts +8 -0
  40. package/src/lib/store/selectors.d.ts +126 -0
  41. package/src/lib/store/selectors.spec.d.ts +1 -0
  42. package/src/lib/store/store.d.ts +15 -0
  43. package/src/lib/store/types.d.ts +30 -0
  44. package/src/lib/{i18n.d.ts → utils/i18n.d.ts} +1 -0
  45. package/src/lib/utils/soundingsLookUp.spec.d.ts +1 -0
  46. package/src/lib/components/SoundingsConnect.d.ts +0 -5
  47. package/src/lib/components/SoundingsMenuBar/LocationsMenu.d.ts +0 -11
  48. package/src/lib/components/SoundingsMenuBar/MenuBar.stories.d.ts +0 -26
  49. package/src/lib/components/SoundingsSourceMenu.d.ts +0 -9
  50. package/src/lib/utils/conversions.d.ts +0 -11
  51. /package/src/lib/{utils/testHandlers.d.ts → api/edr-api/fakeApi.d.ts} +0 -0
  52. /package/src/lib/{components/Soundings.spec.d.ts → api/edr-api/hooks.spec.d.ts} +0 -0
  53. /package/src/lib/components/{SoundingsMenuBar → MenuBar}/LocationsMenuConnect.d.ts +0 -0
  54. /package/src/lib/components/{SoundingsMenuBar → MenuBar}/MenuBarConnect.d.ts +0 -0
  55. /package/src/lib/components/{SoundingsMenuBar/MenuBarConnect.spec.d.ts → MenuBar/MenuBarConnect.integration.spec.d.ts} +0 -0
  56. /package/src/lib/components/{SoundingsSourceDialogConnect.d.ts → MenuBar/SoundingsSourceDialogConnect.d.ts} +0 -0
  57. /package/src/lib/components/{Soundings.stories.d.ts → Soundings/Soundings.stories.d.ts} +0 -0
package/index.esm.js CHANGED
@@ -1,15 +1,97 @@
1
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import React, { useRef, useEffect, useState, Fragment as Fragment$1, useMemo } from 'react';
1
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
+ import { useSelector, useDispatch } from 'react-redux';
3
+ import { Card, Box, Stack, LinearProgress, List, ListItem, ListItemIcon, Checkbox, MenuItem, ListItemText, Typography, Grid, ListItemButton } from '@mui/material';
4
+ import { safelyInitI18nForTestsAndStories, sharedTranslations, SHARED_NAMESPACE, dateUtils, CustomSlider, CustomToggleButton, AlertBanner, ToolContainerDraggable, StyledSelect, SearchField, CustomAccordion } from '@opengeoweb/shared';
5
+ import React, { useRef, useEffect, useMemo, useState, Fragment as Fragment$1 } from 'react';
3
6
  import * as d3 from 'd3';
4
- import { Card, List, ListItem, ListItemIcon, Checkbox, MenuItem, Box, ListItemText, ListSubheader, ListItemButton, Collapse, Button } from '@mui/material';
5
- import { useSetupDialog, uiTypes, uiSelectors, uiActions } from '@opengeoweb/store';
6
- import { ToolContainerDraggable, StyledSelect, dateUtils } from '@opengeoweb/shared';
7
- import 'i18next';
8
- import { useTranslation } from 'react-i18next';
7
+ import { useQueries, useQuery } from '@tanstack/react-query';
9
8
  import { getHeaders, handleResponse } from '@opengeoweb/api';
10
- import { useQueries } from '@tanstack/react-query';
11
- import { ChevronDown, Sources } from '@opengeoweb/theme';
12
- import { useDispatch, useSelector } from 'react-redux';
9
+ import { timesliderTranslations, TIMESLIDER_NAMESPACE, BackwardForwardStepButton } from '@opengeoweb/timeslider';
10
+ import i18n from 'i18next';
11
+ export { default as i18n } from 'i18next';
12
+ import { useTranslation } from 'react-i18next';
13
+ import { createSlice, createSelector, combineReducers, configureStore } from '@reduxjs/toolkit';
14
+ import { selectorMemoizationOptions, uiSelectors, uiTypes, uiActions, useSetupDialog, storeReducerMap, storeMiddlewares } from '@opengeoweb/store';
15
+ import { Sources } from '@opengeoweb/theme';
16
+ import { FixedSizeList } from 'react-window';
17
+
18
+ var en = {
19
+ sources: "Sources",
20
+ latest: "Latest",
21
+ noInstancesAvailable: "No instances found",
22
+ noServicesFound: "No source available",
23
+ locations: "Locations",
24
+ "locations-menu-warning": "You have to choose your sources in the sources menu"
25
+ };
26
+ var no = {
27
+ sources: "Kilder",
28
+ latest: "Siste",
29
+ noInstancesAvailable: "Ingen instanser funnet",
30
+ noServicesFound: "Ingen tjenester funnet",
31
+ locations: "Lokasjoner",
32
+ "locations-menu-warning": "Du må velge kilder i kilde-menyen"
33
+ };
34
+ var nl = {
35
+ sources: "Bronnen",
36
+ latest: "Nieuwste",
37
+ noInstancesAvailable: "Geen exemplaren gevonden",
38
+ noServicesFound: "Geen bron beschikbaar",
39
+ locations: "Locaties",
40
+ "locations-menu-warning": "Selecteer eerst een bron in het bron menu"
41
+ };
42
+ var fi = {
43
+ sources: "Lähteet",
44
+ latest: "Viimeisin",
45
+ noInstancesAvailable: "Tapauksia ei löytynyt",
46
+ noServicesFound: "Lähdettä ei ole saatavilla",
47
+ locations: "Locations",
48
+ "locations-menu-warning": "Valitse ensin lähde lähdevalikosta"
49
+ };
50
+ var soundingsTranslations = {
51
+ en: en,
52
+ no: no,
53
+ nl: nl,
54
+ fi: fi
55
+ };
56
+
57
+ /* *
58
+ * Licensed under the Apache License, Version 2.0 (the "License");
59
+ * you may not use this file except in compliance with the License.
60
+ * You may obtain a copy of the License at
61
+ *
62
+ * http://www.apache.org/licenses/LICENSE-2.0
63
+ *
64
+ * Unless required by applicable law or agreed to in writing, software
65
+ * distributed under the License is distributed on an "AS IS" BASIS,
66
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
67
+ * See the License for the specific language governing permissions and
68
+ * limitations under the License.
69
+ *
70
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
71
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
72
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
73
+ * */
74
+ const isobarValues = [200, 300, 400, 500, 700, 850, 925, 1000];
75
+ const drawIsobars = ({
76
+ minTemperature,
77
+ maxTemperature,
78
+ xScale,
79
+ yScale,
80
+ svg
81
+ }) => {
82
+ // Calculate X and Y coordinates for each pressure level
83
+ for (const pressureVal of isobarValues) {
84
+ const Px = [];
85
+ const tempBounds = [minTemperature, maxTemperature];
86
+ for (const bound of tempBounds) {
87
+ Px.push([xScale(bound), yScale(pressureVal)]);
88
+ }
89
+ const lineGenerator = d3.line();
90
+ const isoPathString = lineGenerator(Px);
91
+ const strID = `${pressureVal}textId`;
92
+ svg.append('path').attr('id', strID).attr('type', 'isobar').attr('d', isoPathString).style('fill', 'none').style('stroke-width', 1.5).style('stroke', '#B5B51F').style('stroke-opacity', 0.5);
93
+ }
94
+ };
13
95
 
14
96
  /* *
15
97
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -28,7 +110,7 @@ import { useDispatch, useSelector } from 'react-redux';
28
110
  * Copyright 2025 - Finnish Meteorological Institute (FMI)
29
111
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
30
112
  * */
31
- const SOUNDING_COLORS = ['#4487f3'];
113
+ const SOUNDING_COLORS = ['#4487f3', '#c3213c', '#509a00'];
32
114
  // vfx code
33
115
  const initializeSvg = ({
34
116
  svgRef,
@@ -38,20 +120,33 @@ const initializeSvg = ({
38
120
  }) => {
39
121
  const svg = d3.select(svgRef.current).attr('width', width + (margin.left + margin.right)).attr('height', height + (margin.top + margin.bottom)).attr('id', 'skewT').attr('data-testid', 'skewT').append('g').attr('transform', `translate(${margin.left + margin.right},${margin.top})`);
40
122
  svg.append('clipPath').attr('id', 'clip-skewTd3').append('rect').attr('width', width).attr('height', height).attr('x', 0).attr('y', 0);
41
- svg.append('rect').attr('height', height).attr('width', width).attr('fill', 'white').attr('opacity', 1).attr('id', 'skewTd3').attr('clip-path', 'url(#clip-skewTd3)');
123
+ svg.append('rect').attr('height', height).attr('width', width).attr('fill', '#ffffff00').attr('opacity', 1).attr('id', 'skewTd3').attr('clip-path', 'url(#clip-skewTd3)');
42
124
  return svg;
43
125
  };
44
126
  const drawAxes = ({
45
127
  svg,
46
128
  xScale,
47
129
  yScale,
48
- height,
49
- width
130
+ height
50
131
  }) => {
51
132
  // Create x-axis with intervals
52
- svg.append('g').attr('transform', `translate(0,${height})`).call(d3.axisBottom(xScale).ticks(10)).append('text').attr('fill', '#000').attr('x', `${width - 8}px`).attr('y', `${12}px`).attr('dy', '1em').attr('font-size', '12px').attr('text-anchor', 'middle').text('°C');
133
+ const xTicks = xScale.ticks(10);
134
+ const xMiddleValue = xTicks[Math.floor(xTicks.length / 2)];
135
+ svg.append('g').attr('type', 'x-axis').attr('transform', `translate(0,${height})`).call(d3.axisBottom(xScale).tickValues(xTicks).tickSizeInner(0).tickSizeOuter(0));
136
+ // Reformat in the case of the middle tick label
137
+ // to add the unit part
138
+ svg.selectAll('g[type="x-axis"] .tick').filter(function () {
139
+ return parseInt(d3.select(this).select('text').text(), 10) === xMiddleValue;
140
+ }).each(function () {
141
+ const unitPart = '°C';
142
+ d3.select(this).select('text')
143
+ // tspan necessary to use a different font-size
144
+ .append('tspan').text(unitPart).attr('font-size', '11px').attr('dy', '0.04em');
145
+ });
53
146
  // Create y-axis with intervals
54
- svg.append('g').call(d3.axisLeft(yScale).ticks(0, '')).append('text').attr('fill', '#000').attr('transform', 'translate(0)').attr('y', `${0}px`).attr('x', `${ -16}px`).attr('font-size', '12px').attr('dy', '1em').attr('text-anchor', 'middle').text('hPa');
147
+ // Make sure all zeroes are printed (1000 vs 1k)
148
+ const formatTicks = d3.format('.0f');
149
+ svg.append('g').call(d3.axisLeft(yScale).tickValues(isobarValues).tickFormat(formatTicks).tickSizeInner(0).tickSizeOuter(0)).call(g => g.select('.domain').remove()).append('text').attr('fill', 'currentColor').attr('x', '0').attr('y', yScale(400)).attr('font-size', '11px').attr('dy', '-0.9em').attr('text-anchor', 'left').text('hPa');
55
150
  };
56
151
  const addLegends = ({
57
152
  svg,
@@ -72,14 +167,595 @@ const plotSounding = ({
72
167
  height,
73
168
  svg
74
169
  }) => {
170
+ const [filteredPressureValues, filteredTemperatureValues] = filterDomainRangeForParameter(pressureValues, temperatureValues);
75
171
  // Map temp and pressure to pixels
76
- const Px = d3.zip(pressureValues, temperatureValues).map(([pressure, temperature]) => [xScale(temperature), yScale(pressure)]);
172
+ const Px = d3.zip(filteredPressureValues, filteredTemperatureValues).map(([pressure, temperature]) => [xScale(temperature), yScale(pressure)]);
77
173
  // Skew/shear transformation for skew-t
78
174
  const skewedPx = Px.map(([x, y]) => [x + height - y, y]);
79
175
  const lineGenerator = d3.line();
80
176
  const pathString = lineGenerator(skewedPx);
81
177
  svg.append('path').attr('d', pathString).attr('x', 0).attr('y', 0).style('fill', 'none').style('stroke-width', 2).style('stroke', color).style('stroke-opacity', 1).style('stroke-dasharray', lineStyle).attr('clip-path', 'url(#clip-skewTd3)');
82
178
  };
179
+ // Use this function to filter the pressure and parameter array so no NaN or null values are included
180
+ const filterDomainRangeForParameter = (pressureDomain, parameterDomain) => {
181
+ const filteredPressureDomain = pressureDomain.filter((value, index) => !isNaN(parameterDomain[index]) && parameterDomain[index] !== null);
182
+ const filteredParameterDomain = parameterDomain.filter(value => !isNaN(value) && value !== null);
183
+ return [filteredPressureDomain, filteredParameterDomain];
184
+ };
185
+
186
+ /* *
187
+ * Licensed under the Apache License, Version 2.0 (the "License");
188
+ * you may not use this file except in compliance with the License.
189
+ * You may obtain a copy of the License at
190
+ *
191
+ * http://www.apache.org/licenses/LICENSE-2.0
192
+ *
193
+ * Unless required by applicable law or agreed to in writing, software
194
+ * distributed under the License is distributed on an "AS IS" BASIS,
195
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
196
+ * See the License for the specific language governing permissions and
197
+ * limitations under the License.
198
+ *
199
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
200
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
201
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
202
+ * */
203
+ // Check if the value is outside the domain, if so, return NaN
204
+ // Otherwise, return the interpolated value
205
+ const createClampedInterpolationFunc = (interpolationFunc, domain) => {
206
+ return value => {
207
+ if (value < domain[domain.length - 1] || value > domain[0]) {
208
+ return NaN;
209
+ }
210
+ return interpolationFunc(value);
211
+ };
212
+ };
213
+ // Construct interpolation functions to use when hovering between data points
214
+ // Ensure NaN/null values are removed and cut the domain to the upper and lower pressure bounds of the parameter
215
+ const constructTooltipData = data => {
216
+ const tooltipData = data.map(features => {
217
+ const pressureData = features.map(feature => feature.properties.pressure);
218
+ const tempData = features.map(feature => feature.properties.temp);
219
+ const dewpointData = features.map(feature => feature.properties.dewpoint);
220
+ const windDirData = features.map(feature => feature.properties.windDirection);
221
+ const windSpeedData = features.map(feature => feature.properties.windSpeed);
222
+ const interpolatePressure = d3.scaleLinear().domain(pressureData).range(pressureData).clamp(true);
223
+ const [tempPressure, filteredTempData] = filterDomainRangeForParameter(pressureData, tempData);
224
+ const interpolateTemperature = d3.scaleLinear().domain(tempPressure).range(filteredTempData).clamp(true);
225
+ const [dewpointPressure, filteredDewpointData] = filterDomainRangeForParameter(pressureData, dewpointData);
226
+ const interpolateDewpoint = d3.scaleLinear().domain(dewpointPressure).range(filteredDewpointData).clamp(true);
227
+ const [windSpeedPressure, filteredWindSpeedData] = filterDomainRangeForParameter(pressureData, windSpeedData);
228
+ const interpolateWindSpeed = d3.scaleLinear().domain(windSpeedPressure).range(filteredWindSpeedData).clamp(true);
229
+ const [windDirPressure, filteredWindDirData] = filterDomainRangeForParameter(pressureData, windDirData);
230
+ const interpolateWindDirection = d3.scaleLinear().domain(windDirPressure).range(filteredWindDirData).clamp(true);
231
+ return {
232
+ pressureData,
233
+ interpolatePressure: createClampedInterpolationFunc(interpolatePressure, pressureData),
234
+ interpolateTemperature: createClampedInterpolationFunc(interpolateTemperature, tempPressure),
235
+ interpolateDewpoint: createClampedInterpolationFunc(interpolateDewpoint, dewpointPressure),
236
+ interpolateWindSpeed: createClampedInterpolationFunc(interpolateWindSpeed, windSpeedPressure),
237
+ interpolateWindDirection: createClampedInterpolationFunc(interpolateWindDirection, windDirPressure)
238
+ };
239
+ });
240
+ return tooltipData.filter(data => data.pressureData.length > 0);
241
+ };
242
+ const getParamHtml = (paramValue, colorIndex, unit, isLastElement, noDistance = false) => {
243
+ if (colorIndex === 0) {
244
+ return `<p style="${noDistance || isLastElement ? 'margin: 0;' : 'margin-top: 0;'} color: ${SOUNDING_COLORS[colorIndex]}">${!isNaN(paramValue) && paramValue !== null ? `${paramValue.toFixed(1)}${unit}` : '-'}${noDistance ? '' : '<br/>'}</p>`;
245
+ }
246
+ if (isLastElement) {
247
+ return `<p style="${noDistance ? 'margin: 0;' : 'margin-bottom: 0;'} color: ${SOUNDING_COLORS[colorIndex]}">${!isNaN(paramValue) && paramValue !== null ? `${paramValue.toFixed(1)}${unit}` : '-'}</p>`;
248
+ }
249
+ return `<p style="${noDistance ? 'margin: 0;' : 'margin-top: 0;'} color: ${SOUNDING_COLORS[colorIndex]}">${!isNaN(paramValue) && paramValue !== null ? `${paramValue.toFixed(1)}${unit}` : '-'}${noDistance ? '' : '<br/>'}</p>`;
250
+ };
251
+ const drawSoundingsTooltip = ({
252
+ event,
253
+ yScale,
254
+ svgRectangle,
255
+ pressureRefd3,
256
+ tempRefd3,
257
+ dewpointRefd3,
258
+ windRefd3,
259
+ margin,
260
+ width,
261
+ tooltipData
262
+ }) => {
263
+ if (tooltipData.length === 0) {
264
+ return;
265
+ }
266
+ const [, y] = d3.pointer(event);
267
+ const pressureY = event.clientY - svgRectangle.top - 10;
268
+ const pressure = yScale.invert(y);
269
+ const interpolatedData = tooltipData.map(dataSet => {
270
+ return {
271
+ hoveredPressure: dataSet.interpolatePressure(pressure),
272
+ hoveredTemp: dataSet.interpolateTemperature(pressure),
273
+ hoveredDewpoint: dataSet.interpolateDewpoint(pressure),
274
+ hoveredWindDirection: dataSet.interpolateWindDirection(pressure),
275
+ hoveredWindSpeed: dataSet.interpolateWindSpeed(pressure)
276
+ };
277
+ });
278
+ // Pressure
279
+ pressureRefd3.style('display', 'block').style('left', `${margin.left + margin.right + 4}px`) // 4 to move it slightly away from y-axis
280
+ .style('top', `${pressureY}px`).html(`${Math.round(pressure)}hPa`);
281
+ // Temperature
282
+ tempRefd3.style('display', 'block').style('left', `${0.75 * width + margin.left}px`).style('top', `${pressureY}px`).html(() => interpolatedData.map((dataSet, index) => {
283
+ return getParamHtml(dataSet.hoveredTemp, index, '°C', index === interpolatedData.length - 1);
284
+ }).join(''));
285
+ // Dewpoint temperature
286
+ dewpointRefd3.style('display', 'block').style('left', `${width / 3 + margin.left}px`).style('top', `${pressureY}px`).html(interpolatedData.map((dataSet, index) => {
287
+ return getParamHtml(dataSet.hoveredDewpoint, index, '°C', index === interpolatedData.length - 1);
288
+ }).join(''));
289
+ // Wind
290
+ windRefd3.style('display', 'block').style('left', `${width + margin.left + margin.right - 55}px`) // -55 to account for width of element
291
+ .style('top', `${pressureY}px`).html(() => {
292
+ const windSpeedStrings = interpolatedData.map((dataSet, index) => {
293
+ return getParamHtml(dataSet.hoveredWindSpeed, index, 'm/s', index === interpolatedData.length - 1, true);
294
+ });
295
+ const windDirStrings = interpolatedData.map((dataSet, index) => {
296
+ return getParamHtml(dataSet.hoveredWindDirection, index, '°', index === interpolatedData.length - 1, index === interpolatedData.length - 1);
297
+ });
298
+ return windSpeedStrings.map((windSpeedString, index) => {
299
+ return `${windSpeedString}${windDirStrings[index]}`;
300
+ }).join('');
301
+ });
302
+ };
303
+
304
+ /* *
305
+ * Licensed under the Apache License, Version 2.0 (the "License");
306
+ * you may not use this file except in compliance with the License.
307
+ * You may obtain a copy of the License at
308
+ *
309
+ * http://www.apache.org/licenses/LICENSE-2.0
310
+ *
311
+ * Unless required by applicable law or agreed to in writing, software
312
+ * distributed under the License is distributed on an "AS IS" BASIS,
313
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
314
+ * See the License for the specific language governing permissions and
315
+ * limitations under the License.
316
+ *
317
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
318
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
319
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
320
+ * */
321
+ const windSymbolDefinitions = {
322
+ calm: `<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
323
+ <path d="M25 45C36.0456 45 45 36.0456 45 25C45 13.9543 36.0456 5 25 5C13.9543 5 5 13.9543 5 25C5 36.0456 13.9543 45 25 45Z" stroke="black" stroke-width="1.8"/>
324
+ </svg>
325
+ `,
326
+ lightAir: `<svg width="14" height="48" viewBox="0 0 14 48" fill="none" xmlns="http://www.w3.org/2000/svg">
327
+ <path d="M7.22656 4.89307V48.0001M7.22656 43.0262V44.6842" stroke="black" stroke-width="1.8"/>
328
+ </svg>
329
+ `,
330
+ fiveKnots: `<svg width="15" height="49" viewBox="0 0 15 49" fill="none" xmlns="http://www.w3.org/2000/svg">
331
+ <path d="M8.26953 5.57324V48.608M8.26953 40.5757L0.269531 42.2309" stroke="black" stroke-width="1.8"/>
332
+ </svg>`,
333
+ tenKnots: `<svg width="24" height="49" viewBox="0 0 24 49" fill="none" xmlns="http://www.w3.org/2000/svg">
334
+ <path d="M17.0781 4.64502V44.9031L1.07812 47.9999" stroke="black" stroke-width="1.8"/>
335
+ </svg>`,
336
+ fifteenKnots: `<svg width="23" height="50" viewBox="0 0 23 50" fill="none" xmlns="http://www.w3.org/2000/svg">
337
+ <path d="M16.4727 5.55469V45.8128L0.472656 48.9095M16.4727 38.0708L9.61551 39.6192" stroke="black" stroke-width="1.8"/>
338
+ </svg>
339
+ `,
340
+ twentyKnots: `<svg width="24" height="49" viewBox="0 0 24 49" fill="none" xmlns="http://www.w3.org/2000/svg">
341
+ <path d="M16.8555 4.71924V44.9773L0.855469 48.0741M16.8555 38.4093L0.855469 41.5061" stroke="black" stroke-width="1.8"/>
342
+ </svg>`,
343
+ twentyFiveKnots: `<svg width="23" height="51" viewBox="0 0 23 51" fill="none" xmlns="http://www.w3.org/2000/svg">
344
+ <path d="M16.2383 4.94434V46.1541L0.238281 49.3241M16.2383 40.3787L0.238281 43.5486M16.2383 33.9801L9.38114 35.5651" stroke="black" stroke-width="1.8"/>
345
+ </svg>`,
346
+ thirtyKnots: `<svg width="23" height="50" viewBox="0 0 23 50" fill="none" xmlns="http://www.w3.org/2000/svg">
347
+ <path d="M16.1914 5.64502V45.9031L0.191406 48.9999M16.1914 40.3459L0.191406 43.4427M16.1914 34.9407L0.191406 38.0375" stroke="black" stroke-width="1.8"/>
348
+ </svg>`,
349
+ thirtyFiveKnots: `<svg width="19" height="50" viewBox="0 0 19 50" fill="none" xmlns="http://www.w3.org/2000/svg">
350
+ <path d="M12.0028 4.96729V45.2253L1.16406 48.3221M12.0028 39.6443L1.16406 42.7411M12.0028 34.4793L1.16406 37.576M12.0028 29.2735L7.35761 30.8219" stroke="black" stroke-width="1.8"/>
351
+ </svg>`,
352
+ fortyKnots: `<svg width="24" height="50" viewBox="0 0 24 50" fill="none" xmlns="http://www.w3.org/2000/svg">
353
+ <path d="M16.5391 5.40311V45.6612L0.539062 48.7579M16.5391 40.4525L0.539062 43.5493M16.5391 35.0449L0.539062 38.1417M16.5391 30.2009L0.539062 33.2976" stroke="black" stroke-width="1.8"/>
354
+ </svg>`,
355
+ fortyFiveKnots: `<svg width="23" height="50" viewBox="0 0 23 50" fill="none" xmlns="http://www.w3.org/2000/svg">
356
+ <path d="M16.4492 5.4043V45.6624L0.449219 48.7591M16.4492 40.1513L0.449219 43.2481M16.4492 34.7124L0.449219 37.8091M16.4492 29.2013L0.449219 32.2981M16.4492 24.3066L9.59208 25.855" stroke="black" stroke-width="1.8"/>
357
+ </svg>`,
358
+ fiftyKnots: `<svg width="24" height="49" viewBox="0 0 24 49" fill="none" xmlns="http://www.w3.org/2000/svg">
359
+ <path d="M16.9062 5.27002V45.5281L0.90625 48.6249L16.9062 37.7861" fill="black"/>
360
+ <path d="M16.9053 5.27002V45.5281L4.35132 47.2337L16.9053 37.7861" stroke="black" stroke-width="1.8"/>
361
+ </svg>
362
+ `,
363
+ fiftyFiveKnots: `<svg width="24" height="49" viewBox="0 0 24 49" fill="none" xmlns="http://www.w3.org/2000/svg">
364
+ <path d="M16.728 5.55371V45.8118L0.894531 48.9085L16.728 38.0698" fill="black"/>
365
+ <path d="M16.73 5.55371V45.8118L4.33984 47.5245L16.73 38.0698" stroke="black" stroke-width="1.8"/>
366
+ <path d="M16.7292 32.7358C13.6376 33.3406 8.8125 34.2842 8.8125 34.2842" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
367
+ </svg>`,
368
+ sixtyKnots: `<svg width="23" height="49" viewBox="0 0 23 49" fill="none" xmlns="http://www.w3.org/2000/svg">
369
+ <path d="M16.2891 5.27051V45.5286L0.289062 48.6253L16.2891 37.7866" fill="black"/>
370
+ <path d="M16.2897 5.27051V45.5286L3.7422 47.2478L16.2897 37.7866" stroke="black" stroke-width="1.8"/>
371
+ <path d="M16.2891 34.6899L0.289062 37.7867" stroke="black" stroke-width="1.8"/>
372
+ </svg>`,
373
+ sixtyFiveKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
374
+ <path d="M17 3.26807V43.5261L1 46.6229L17 35.7842" fill="black"/>
375
+ <path d="M16.9984 3.26807V43.5261L4.35938 45.2985L16.9984 35.7842" stroke="black" stroke-width="1.8"/>
376
+ <path d="M17 32.0788L1 35.1756M17 26.8862L9.5 28.5" stroke="black" stroke-width="1.8"/>
377
+ </svg>`,
378
+ seventyKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
379
+ <path d="M17 3.26807V43.5261L1 46.6229L17 35.7842" fill="black"/>
380
+ <path d="M16.9984 3.26807V43.5261L4.35938 45.2985L16.9984 35.7842" stroke="black" stroke-width="1.8"/>
381
+ <path d="M17 32.0788L1 35.1756M17 26.8862L1 29.983" stroke="black" stroke-width="1.8"/>
382
+ </svg>`,
383
+ seventyFiveKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
384
+ <path d="M17 3.26807V43.5261L1 46.6229L17 35.7842" fill="black"/>
385
+ <path d="M16.9984 3.26807V43.5261L4.35938 45.2985L16.9984 35.7842" stroke="black" stroke-width="1.8"/>
386
+ <path d="M17 32.0788L1 35.1756M17 26.8862L1 29.983" stroke="black" stroke-width="1.8"/>
387
+ <path d="M16.9167 22C13.8251 22.6048 9 23.5484 9 23.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
388
+ </svg>`,
389
+ eightyKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
390
+ <path d="M17 3.26807V43.5261L1 46.6229L17 35.7842" fill="black"/>
391
+ <path d="M16.9984 3.26807V43.5261L4.35938 45.2985L16.9984 35.7842" stroke="black" stroke-width="1.8"/>
392
+ <path d="M17 32.0788L1 35.1756M17 26.8862L1 29.983" stroke="black" stroke-width="1.8"/>
393
+ <path d="M17 22C10.7518 23.1718 1 25 1 25" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
394
+ </svg>`,
395
+ eightyFiveKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
396
+ <path d="M17 3.26807V43.5261L1 46.6229L17 35.7842" fill="black"/>
397
+ <path d="M16.9984 3.26807V43.5261L4.35938 45.2985L16.9984 35.7842" stroke="black" stroke-width="1.8"/>
398
+ <path d="M16.9167 17C13.8251 17.6048 9 18.5484 9 18.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
399
+ <path d="M17 32.0788L1 35.1756M17 26.8862L1 29.983" stroke="black" stroke-width="1.8"/>
400
+ <path d="M17 22C10.7518 23.1718 1 25 1 25" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
401
+ </svg>
402
+ `,
403
+ ninetyKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
404
+ <path d="M17 3.26807V43.5261L1 46.6229L17 35.7842" fill="black"/>
405
+ <path d="M16.9984 3.26807V43.5261L4.35938 45.2985L16.9984 35.7842" stroke="black" stroke-width="1.8"/>
406
+ <path d="M17 32.0788L1 35.1756M17 26.8862L1 29.983" stroke="black" stroke-width="1.8"/>
407
+ <path d="M17 22C10.7518 23.1718 1 25 1 25" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
408
+ <path d="M17 17C10.7518 18.1718 1 20 1 20" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
409
+ </svg>
410
+ `,
411
+ ninetyFiveKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
412
+ <path d="M17 3.26807V43.5261L1 46.6229L17 35.7842" fill="black"/>
413
+ <path d="M16.9984 3.26807V43.5261L4.35938 45.2985L16.9984 35.7842" stroke="black" stroke-width="1.8"/>
414
+ <path d="M17 32.0788L1 35.1756M17 26.8862L1 29.983" stroke="black" stroke-width="1.8"/>
415
+ <path d="M17 22C10.7518 23.1718 1 25 1 25" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
416
+ <path d="M17 17C10.7518 18.1718 1 20 1 20" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
417
+ <path d="M16.9167 12C13.8251 12.6048 9 13.5484 9 13.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
418
+ </svg>`,
419
+ hundredKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
420
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
421
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
422
+ </svg>
423
+ `,
424
+ hundredFiveKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
425
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
426
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
427
+ <path d="M16.9167 25C13.8251 25.6048 9 26.5484 9 26.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
428
+ </svg>
429
+ `,
430
+ hundredTenKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
431
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
432
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
433
+ <path d="M17 25C10.7518 26.1718 1 28 1 28" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
434
+ </svg>`,
435
+ hundredFifteenKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
436
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
437
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
438
+ <path d="M17 25C10.7518 26.1718 1 28 1 28" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
439
+ <path d="M16.9167 21C13.8251 21.6048 9 22.5484 9 22.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
440
+ </svg>`,
441
+ hundredTwentyKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
442
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
443
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
444
+ <path d="M17 25C10.7518 26.1718 1 28 1 28" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
445
+ <path d="M17 21C10.7518 22.1718 1 24 1 24" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
446
+ </svg>`,
447
+ hundredTwentyFiveKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
448
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
449
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
450
+ <path d="M17 25C10.7518 26.1718 1 28 1 28" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
451
+ <path d="M17 21C10.7518 22.1718 1 24 1 24" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
452
+ <path d="M16.9167 17C13.8251 17.6048 9 18.5484 9 18.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
453
+ </svg>
454
+ `,
455
+ hundredThirtyKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
456
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
457
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
458
+ <path d="M17 25C10.7518 26.1718 1 28 1 28" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
459
+ <path d="M17 21C10.7518 22.1718 1 24 1 24" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
460
+ <path d="M17 17C10.7518 18.1718 1 20 1 20" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
461
+ </svg>`,
462
+ hundredThirtyFiveKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
463
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
464
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
465
+ <path d="M17 25C10.7518 26.1718 1 28 1 28" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
466
+ <path d="M17 21C10.7518 22.1718 1 24 1 24" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
467
+ <path d="M17 17C10.7518 18.1718 1 20 1 20" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
468
+ <path d="M16.9167 13C13.8251 13.6048 9 14.5484 9 14.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
469
+ </svg>`,
470
+ hundredFortyKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
471
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
472
+ <path d="M16.9984 3.26807V28M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34" stroke="black" stroke-width="1.8"/>
473
+ <path d="M17 25C10.7518 26.1718 1 28 1 28" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
474
+ <path d="M17 21C10.7518 22.1718 1 24 1 24" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
475
+ <path d="M17 17C10.7518 18.1718 1 20 1 20" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
476
+ <path d="M17 13C10.7518 14.1718 1 16 1 16" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
477
+ </svg>`,
478
+ hundredFortyFiveKnots: `<svg width="24" height="49" viewBox="0 0 24 49" fill="none" xmlns="http://www.w3.org/2000/svg">
479
+ <path d="M17 5.26807V25.3971V45.5261L1 48.6229L17 37.7842" fill="black"/>
480
+ <path d="M16.9984 5.26807V30M16.9984 37.7842L4.35938 47.2985L16.9984 45.5261V37.5M16.9984 30C16.9984 30 5.67406 37.7842 4 39M16.9984 30V31.5M4 39C9.07621 38.4142 16.9984 37.5 16.9984 37.5M4 39C9.07621 36.0711 16.9984 31.5 16.9984 31.5M4 39C9.07682 36.6569 17 33 17 33M4 39C9.07621 37.2426 16.9984 34.5 16.9984 34.5M4 39C9.07621 37.8284 16.9984 36 16.9984 36M16.9984 37.5V36M16.9984 31.5V34.5M16.9984 34.5V36" stroke="black" stroke-width="1.8"/>
481
+ <path d="M16.9167 12C13.8251 12.6048 9 13.5484 9 13.5484" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
482
+ <path d="M17 27C10.7518 28.1718 1 30 1 30" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
483
+ <path d="M17 23C10.7518 24.1718 1 26 1 26" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
484
+ <path d="M17 19C10.7518 20.1718 1 22 1 22" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
485
+ <path d="M17 15C10.7518 16.1718 1 18 1 18" stroke="black" stroke-width="1.8" stroke-miterlimit="16"/>
486
+ </svg>`,
487
+ hundredFiftyKnots: `<svg width="24" height="47" viewBox="0 0 24 47" fill="none" xmlns="http://www.w3.org/2000/svg">
488
+ <path d="M17 3.26807V23.3971V43.5261L1 46.6229L17 35.7842" fill="black"/>
489
+ <path d="M16.9984 3.26807V19M16.9984 35.7842L4.35938 45.2985L16.9984 43.5261V35.5M16.9984 28C16.9984 28 5.67406 35.7842 4 37M16.9984 28V29.5M16.9984 28V26.5M4 37C9.07621 36.4142 16.9984 35.5 16.9984 35.5M4 37C9.07621 34.0711 16.9984 29.5 16.9984 29.5M4 37C9.07682 34.6569 17 31 17 31M4 37C9.07621 35.2426 16.9984 32.5 16.9984 32.5M4 37C9.07621 35.8284 16.9984 34 16.9984 34M16.9984 35.5V34M16.9984 29.5V32.5M16.9984 32.5V34M16.9984 19C16.9984 19 8 25.5 4 29M16.9984 19V20.5M4 29C9.07621 28.0237 16.9984 26.5 16.9984 26.5M4 29C9.07621 25.6805 16.9984 20.5 16.9984 20.5M4 29C9.07621 26.4616 16.9984 22.5 16.9984 22.5M4 29C9.07621 27.0474 16.9984 24 16.9984 24M4 29C9.07621 27.5355 16.9984 25.25 16.9984 25.25M16.9984 26.5V25.25M16.9984 20.5V22.5M16.9984 22.5V24M16.9984 24V25.25" stroke="black" stroke-width="1.8"/>
490
+ </svg>`
491
+ };
492
+ const windSpeedRanges = [{
493
+ min: 0,
494
+ max: 1,
495
+ symbol: windSymbolDefinitions.calm
496
+ }, {
497
+ min: 1,
498
+ max: 4.5,
499
+ symbol: windSymbolDefinitions.lightAir
500
+ }, {
501
+ min: 4.5,
502
+ max: 7.5,
503
+ symbol: windSymbolDefinitions.fiveKnots
504
+ }, {
505
+ min: 7.5,
506
+ max: 12.5,
507
+ symbol: windSymbolDefinitions.tenKnots
508
+ }, {
509
+ min: 12.5,
510
+ max: 17.5,
511
+ symbol: windSymbolDefinitions.fifteenKnots
512
+ }, {
513
+ min: 17.5,
514
+ max: 22.5,
515
+ symbol: windSymbolDefinitions.twentyKnots
516
+ }, {
517
+ min: 22.5,
518
+ max: 27.5,
519
+ symbol: windSymbolDefinitions.twentyFiveKnots
520
+ }, {
521
+ min: 27.5,
522
+ max: 32.5,
523
+ symbol: windSymbolDefinitions.thirtyKnots
524
+ }, {
525
+ min: 32.5,
526
+ max: 37.5,
527
+ symbol: windSymbolDefinitions.thirtyFiveKnots
528
+ }, {
529
+ min: 37.5,
530
+ max: 42.5,
531
+ symbol: windSymbolDefinitions.fortyKnots
532
+ }, {
533
+ min: 42.5,
534
+ max: 47.5,
535
+ symbol: windSymbolDefinitions.fortyFiveKnots
536
+ }, {
537
+ min: 47.5,
538
+ max: 52.5,
539
+ symbol: windSymbolDefinitions.fiftyKnots
540
+ }, {
541
+ min: 52.5,
542
+ max: 57.5,
543
+ symbol: windSymbolDefinitions.fiftyFiveKnots
544
+ }, {
545
+ min: 57.5,
546
+ max: 62.5,
547
+ symbol: windSymbolDefinitions.sixtyKnots
548
+ }, {
549
+ min: 62.5,
550
+ max: 67.5,
551
+ symbol: windSymbolDefinitions.sixtyFiveKnots
552
+ }, {
553
+ min: 67.5,
554
+ max: 72.5,
555
+ symbol: windSymbolDefinitions.seventyKnots
556
+ }, {
557
+ min: 72.5,
558
+ max: 77.5,
559
+ symbol: windSymbolDefinitions.seventyFiveKnots
560
+ }, {
561
+ min: 77.5,
562
+ max: 82.5,
563
+ symbol: windSymbolDefinitions.eightyKnots
564
+ }, {
565
+ min: 82.5,
566
+ max: 87.5,
567
+ symbol: windSymbolDefinitions.eightyFiveKnots
568
+ }, {
569
+ min: 87.5,
570
+ max: 92.5,
571
+ symbol: windSymbolDefinitions.ninetyKnots
572
+ }, {
573
+ min: 92.5,
574
+ max: 97.5,
575
+ symbol: windSymbolDefinitions.ninetyFiveKnots
576
+ }, {
577
+ min: 97.5,
578
+ max: 102.5,
579
+ symbol: windSymbolDefinitions.hundredKnots
580
+ }, {
581
+ min: 102.5,
582
+ max: 107.5,
583
+ symbol: windSymbolDefinitions.hundredFiveKnots
584
+ }, {
585
+ min: 107.5,
586
+ max: 112.5,
587
+ symbol: windSymbolDefinitions.hundredTenKnots
588
+ }, {
589
+ min: 112.5,
590
+ max: 117.5,
591
+ symbol: windSymbolDefinitions.hundredFifteenKnots
592
+ }, {
593
+ min: 117.5,
594
+ max: 122.5,
595
+ symbol: windSymbolDefinitions.hundredTwentyKnots
596
+ }, {
597
+ min: 122.5,
598
+ max: 127.5,
599
+ symbol: windSymbolDefinitions.hundredTwentyFiveKnots
600
+ }, {
601
+ min: 127.5,
602
+ max: 132.5,
603
+ symbol: windSymbolDefinitions.hundredThirtyKnots
604
+ }, {
605
+ min: 132.5,
606
+ max: 137.5,
607
+ symbol: windSymbolDefinitions.hundredThirtyFiveKnots
608
+ }, {
609
+ min: 137.5,
610
+ max: 142.5,
611
+ symbol: windSymbolDefinitions.hundredFortyKnots
612
+ }, {
613
+ min: 142.5,
614
+ max: 147.5,
615
+ symbol: windSymbolDefinitions.hundredFortyFiveKnots
616
+ }, {
617
+ min: 147.5,
618
+ max: 152,
619
+ symbol: windSymbolDefinitions.hundredFiftyKnots
620
+ }];
621
+
622
+ /* *
623
+ * Licensed under the Apache License, Version 2.0 (the "License");
624
+ * you may not use this file except in compliance with the License.
625
+ * You may obtain a copy of the License at
626
+ *
627
+ * http://www.apache.org/licenses/LICENSE-2.0
628
+ *
629
+ * Unless required by applicable law or agreed to in writing, software
630
+ * distributed under the License is distributed on an "AS IS" BASIS,
631
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
632
+ * See the License for the specific language governing permissions and
633
+ * limitations under the License.
634
+ *
635
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
636
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
637
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
638
+ * */
639
+ const metersPerSecondToKnots = metersPerSecond => metersPerSecond * 1.94384;
640
+ const extendIsoBar = (path, horizontalBarbMargin) => {
641
+ const match = path.match(/^M([\d.-]+),([\d.-]+)\s*L([\d.-]+),([\d.-]+)/);
642
+ if (match) {
643
+ const startX = parseFloat(match[1]);
644
+ const startY = parseFloat(match[2]);
645
+ const endX = parseFloat(match[3]);
646
+ const endY = parseFloat(match[4]);
647
+ return `M${startX},${startY}L${endX + horizontalBarbMargin * 3},${endY}`;
648
+ }
649
+ return path;
650
+ };
651
+ const extendXAxis = (path, horizontalBarbMargin) => {
652
+ const match = path.match(/^M([\d.-]+),([\d.-]+)H([\d.-]+)/);
653
+ if (match) {
654
+ const endX = parseFloat(match[3]);
655
+ return path.replace(/H[\d.-]+/, `H${endX + horizontalBarbMargin * 3}`);
656
+ }
657
+ return path;
658
+ };
659
+ const drawWindBarbs = ({
660
+ svgRef,
661
+ data,
662
+ width,
663
+ height,
664
+ margin,
665
+ minPressure,
666
+ maxPressure,
667
+ barbColor = 'black'
668
+ }) => {
669
+ const barbsSize = 25;
670
+ const scalingFactor = 0.5;
671
+ const horizontalBarbMargin = barbsSize * scalingFactor;
672
+ const visibleItems = removeItemsExponential(data.reverse()).filter(item => item.pressure >= 150);
673
+ // Measure width and height and make extra room for one column
674
+ // of windbarbs
675
+ const svg = d3.select(svgRef.current);
676
+ const currentWidth = svg.node()?.getBoundingClientRect().width;
677
+ if (!currentWidth) {
678
+ return;
679
+ }
680
+ // Add extra width to all isobars needed for the windbarbs
681
+ d3.selectAll('path[type="isobar"]').each(function () {
682
+ const path = d3.select(this).attr('d');
683
+ const newD = extendIsoBar(path, horizontalBarbMargin);
684
+ d3.select(this).attr('d', newD).attr('clip-path', null);
685
+ });
686
+ // Add extra width to x-axis needed for the windbarbs
687
+ d3.select('g[type="x-axis"] path.domain').each(function () {
688
+ const path = d3.select(this).attr('d');
689
+ const newD = extendXAxis(path, horizontalBarbMargin);
690
+ d3.select(this).attr('d', newD);
691
+ });
692
+ svg.attr('width', currentWidth + margin.left + horizontalBarbMargin);
693
+ const pressureScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]).clamp(true);
694
+ const barbGroup = svg.append('g').attr('class', 'wind-group').attr('transform',
695
+ // This translation should be revisited when implementing
696
+ // the plotting of several datasets at once
697
+ `translate(${(currentWidth ?? width) + horizontalBarbMargin}, ${margin.top})`);
698
+ barbGroup.selectAll('g').data(visibleItems).enter().append('g').attr('id', 'wind-barb').each(function handleBarb(data) {
699
+ const group = d3.select(this);
700
+ const windSymbol = getWindSymbol(data.windSpeed);
701
+ if (windSymbol) {
702
+ // Inner group will have rotation and scaling applied to it
703
+ const innerGroup = group.append('g');
704
+ innerGroup.node()?.appendChild(windSymbol);
705
+ // Update stroke color
706
+ innerGroup.selectAll('*').attr('stroke', barbColor);
707
+ // Only update the fill for the paths that already used a fill
708
+ // in their original svg form.
709
+ innerGroup.selectAll('*').each(function () {
710
+ const el = d3.select(this);
711
+ const currentFill = el.attr('fill');
712
+ if (currentFill && currentFill !== 'none') {
713
+ el.attr('fill', barbColor);
714
+ }
715
+ });
716
+ const bbox = windSymbol.getBBox();
717
+ // Central point
718
+ const cx = bbox.x + bbox.width / 2;
719
+ const cy = bbox.y + bbox.height / 2;
720
+ const rotation = 180 + data.windDirection;
721
+ const y = pressureScale(data.pressure);
722
+ // Place items in the correct vertical position
723
+ // Correcting for barbSize
724
+ group.attr('transform', `translate(0, ${y - barbsSize * scalingFactor / 8})`);
725
+ // Rotate accordingly, and scale
726
+ innerGroup.attr('transform', `rotate(${rotation}) scale(${scalingFactor}) translate(${-cx}, ${-cy})`);
727
+ }
728
+ });
729
+ };
730
+ const removeItemsExponential = array => {
731
+ const base = 1.15;
732
+ const indicesToKeep = [];
733
+ let i = 0;
734
+ let nextIndex = 0;
735
+ while (nextIndex < array.length) {
736
+ nextIndex = Math.floor(base ** i);
737
+ indicesToKeep.push(nextIndex);
738
+ i += 1;
739
+ }
740
+ return array.filter((_, index) => indicesToKeep.includes(index));
741
+ };
742
+ const getWindSymbol = value => {
743
+ const valueKts = metersPerSecondToKnots(value);
744
+ const matchedRange = windSpeedRanges.find(({
745
+ min,
746
+ max
747
+ }) => valueKts >= min && valueKts < max);
748
+ // Out of range
749
+ if (!matchedRange) {
750
+ return null;
751
+ }
752
+ // SVG string to actual SVG element
753
+ const parser = new DOMParser();
754
+ const svgDoc = parser.parseFromString(matchedRange.symbol, 'image/svg+xml');
755
+ const svgNode = svgDoc.documentElement;
756
+ // Return the parsed SVG node
757
+ return svgNode;
758
+ };
83
759
 
84
760
  /* *
85
761
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -133,48 +809,6 @@ const drawDryAdiabats = ({
133
809
  }
134
810
  };
135
811
 
136
- /* *
137
- * Licensed under the Apache License, Version 2.0 (the "License");
138
- * you may not use this file except in compliance with the License.
139
- * You may obtain a copy of the License at
140
- *
141
- * http://www.apache.org/licenses/LICENSE-2.0
142
- *
143
- * Unless required by applicable law or agreed to in writing, software
144
- * distributed under the License is distributed on an "AS IS" BASIS,
145
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
146
- * See the License for the specific language governing permissions and
147
- * limitations under the License.
148
- *
149
- * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
150
- * Copyright 2025 - Finnish Meteorological Institute (FMI)
151
- * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
152
- * */
153
- const isobarValues = [200, 300, 400, 500, 700, 850, 925, 1000];
154
- const drawIsobars = ({
155
- minTemperature,
156
- maxTemperature,
157
- xScale,
158
- yScale,
159
- svg
160
- }) => {
161
- // Calculate X and Y coordinates for each pressure level
162
- for (const pressureVal of isobarValues) {
163
- const Px = [];
164
- const tempBounds = [minTemperature, maxTemperature];
165
- const Ppx = yScale(pressureVal);
166
- for (const bound of tempBounds) {
167
- Px.push([xScale(bound), yScale(pressureVal)]);
168
- }
169
- const lineGenerator = d3.line();
170
- const isoPathString = lineGenerator(Px);
171
- const strID = `${pressureVal}textId`;
172
- svg.append('path').attr('id', strID).attr('d', isoPathString).style('fill', 'none').style('stroke-width', 1.5).style('stroke', '#B5B51F').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-skewTd3)');
173
- svg.append('text').text(pressureVal).attr('text-anchor', 'middle').attr('x', `${ -24}px`).attr('y', Ppx + 5) // Y-coordinate of the label
174
- .attr('fill', '#000').attr('font-size', '12px');
175
- }
176
- };
177
-
178
812
  /* *
179
813
  * Licensed under the Apache License, Version 2.0 (the "License");
180
814
  * you may not use this file except in compliance with the License.
@@ -428,14 +1062,58 @@ const drawStaticLines = ({
428
1062
  });
429
1063
  };
430
1064
 
431
- const Soundings = ({
432
- data: {
433
- features
1065
+ const shadow = '0 1px 5px 0 rgba(0, 0, 0, 0.2), 0 3px 4px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.14)';
1066
+ const soundingsStyle = {
1067
+ tooltip: {
1068
+ display: 'block',
1069
+ position: 'absolute',
1070
+ borderRadius: '1.5px',
1071
+ backgroundColor: 'geowebColors.soundings.tooltip.background',
1072
+ boxShadow: shadow,
1073
+ padding: '2px 4px',
1074
+ pointerEvents: 'none',
1075
+ zIndex: 500
1076
+ },
1077
+ pressure: {
1078
+ fontSize: '10px'
1079
+ },
1080
+ temperature: {
1081
+ fontSize: '12px',
1082
+ fontWeight: 'bold'
1083
+ },
1084
+ dewPoint: {
1085
+ fontSize: '12px',
1086
+ fontWeight: 'bold'
434
1087
  },
435
- svgDimensions
436
- // tooltip
1088
+ wind: {
1089
+ fontSize: '12px',
1090
+ fontWeight: 'bold'
1091
+ }
1092
+ };
1093
+ // Define pressure and temperature values for static lines
1094
+ const maxPressure = 1050;
1095
+ const minPressure = 150;
1096
+ const staticPressureArray = [maxPressure];
1097
+ const dp = -5;
1098
+ const minTemperature = -40;
1099
+ const maxTemperature = 40.6;
1100
+ let pressure = maxPressure;
1101
+ while (pressure > minPressure) {
1102
+ pressure += dp;
1103
+ staticPressureArray.push(pressure);
1104
+ }
1105
+ const Soundings = ({
1106
+ data,
1107
+ svgDimensions,
1108
+ showTooltip = true
437
1109
  }) => {
438
1110
  const svgRef = useRef(null);
1111
+ const canvasRef = useRef(null);
1112
+ const pressureRef = useRef(null);
1113
+ const tempRef = useRef(null);
1114
+ const dewpointRef = useRef(null);
1115
+ const windRef = useRef(null);
1116
+ // set default values
439
1117
  const svgDimensionsOptions = {
440
1118
  width: svgDimensions?.width || 812,
441
1119
  height: svgDimensions?.height || 802,
@@ -447,12 +1125,7 @@ const Soundings = ({
447
1125
  }
448
1126
  };
449
1127
  // Set SVG dimensions
450
- const margin = {
451
- top: svgDimensionsOptions?.margin?.top,
452
- right: svgDimensionsOptions?.margin?.right,
453
- bottom: svgDimensionsOptions?.margin?.bottom,
454
- left: svgDimensionsOptions?.margin?.left
455
- };
1128
+ const margin = svgDimensionsOptions?.margin;
456
1129
  const baseWidth = svgDimensionsOptions?.width;
457
1130
  const baseHeight = svgDimensionsOptions?.height;
458
1131
  const width = baseWidth - (margin.left + margin.right);
@@ -468,18 +1141,7 @@ const Soundings = ({
468
1141
  height,
469
1142
  margin
470
1143
  });
471
- // Define pressure and temperature values for static lines
472
- const maxPressure = 1050;
473
- const minPressure = 150;
474
- const staticPressureArray = [maxPressure];
475
- const dp = -5;
476
- const minTemperature = -40;
477
- const maxTemperature = 40;
478
- let pressure = maxPressure;
479
- while (pressure > minPressure) {
480
- pressure += dp;
481
- staticPressureArray.push(pressure);
482
- }
1144
+ const svgRectangle = svgRef.current.getBoundingClientRect();
483
1145
  // Set scales for temperature and pressure
484
1146
  const xScale = d3.scaleLinear().domain([minTemperature, maxTemperature]).range([0, width]);
485
1147
  const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
@@ -488,8 +1150,7 @@ const Soundings = ({
488
1150
  svg,
489
1151
  xScale,
490
1152
  yScale,
491
- height,
492
- width
1153
+ height
493
1154
  });
494
1155
  // Draw static lines
495
1156
  drawStaticLines({
@@ -510,42 +1171,157 @@ const Soundings = ({
510
1171
  svg,
511
1172
  width
512
1173
  });
513
- const temperatureValues = features.map(feature => feature.properties.temp);
514
- const pressureValues = features.map(feature => feature.properties.pressure);
515
- // Draw Temperature
516
- plotSounding({
517
- temperatureValues,
518
- pressureValues,
519
- color: SOUNDING_COLORS[0],
520
- lineStyle: 'none',
521
- xScale,
522
- yScale,
523
- height,
524
- svg
525
- });
526
- const dewpointValues = features.map(feature => feature.properties.dewpoint);
527
- // Dewpoint
528
- plotSounding({
529
- temperatureValues: dewpointValues,
530
- color: SOUNDING_COLORS[0],
531
- pressureValues,
532
- lineStyle: '8, 2',
533
- xScale,
534
- yScale,
535
- height,
536
- svg
1174
+ data.forEach((features, index) => {
1175
+ const color = SOUNDING_COLORS[index] || SOUNDING_COLORS[0];
1176
+ const temperatureValues = features.map(feature => feature.properties.temp);
1177
+ const pressureValues = features.map(feature => feature.properties.pressure);
1178
+ // Draw Temperature
1179
+ plotSounding({
1180
+ temperatureValues,
1181
+ pressureValues,
1182
+ color,
1183
+ lineStyle: 'none',
1184
+ xScale,
1185
+ yScale,
1186
+ height,
1187
+ svg
1188
+ });
1189
+ const dewpointValues = features.map(feature => feature.properties.dewpoint);
1190
+ // Dewpoint
1191
+ plotSounding({
1192
+ temperatureValues: dewpointValues,
1193
+ color,
1194
+ pressureValues,
1195
+ lineStyle: '8, 2',
1196
+ xScale,
1197
+ yScale,
1198
+ height,
1199
+ svg
1200
+ });
1201
+ const windData = features.map(feature => ({
1202
+ windDirection: feature.properties.windDirection,
1203
+ windSpeed: feature.properties.windSpeed,
1204
+ pressure: feature.properties.pressure
1205
+ }));
1206
+ drawWindBarbs({
1207
+ svgRef,
1208
+ data: windData,
1209
+ width,
1210
+ height,
1211
+ margin,
1212
+ minPressure,
1213
+ maxPressure,
1214
+ barbColor: color
1215
+ });
537
1216
  });
1217
+ // Attach mousemove event listener to update current coordinates
1218
+ if (showTooltip) {
1219
+ const pressureRefd3 = d3.select(pressureRef.current);
1220
+ const tempRefd3 = d3.select(tempRef.current);
1221
+ const dewpointRefd3 = d3.select(dewpointRef.current);
1222
+ const windRefd3 = d3.select(windRef.current);
1223
+ const tooltipData = constructTooltipData(data);
1224
+ svg.on('mousemove', event => {
1225
+ drawSoundingsTooltip({
1226
+ event,
1227
+ yScale,
1228
+ svgRectangle,
1229
+ margin,
1230
+ width,
1231
+ pressureRefd3,
1232
+ tempRefd3,
1233
+ dewpointRefd3,
1234
+ windRefd3,
1235
+ tooltipData
1236
+ });
1237
+ });
1238
+ }
1239
+ const svgRefCur = svgRef.current;
538
1240
  // eslint-disable-next-line consistent-return
539
1241
  return () => {
1242
+ d3.select(svgRefCur).selectAll('.wind-group').remove();
540
1243
  svg.selectAll('*').remove();
541
- // svg.on('mousemove', null)
1244
+ svg.on('mousemove', null);
542
1245
  };
543
1246
  // eslint-disable-next-line react-hooks/exhaustive-deps
1247
+ }, [showTooltip, data]);
1248
+ useEffect(() => {
1249
+ const handleMouseLeave = () => {
1250
+ if (pressureRef.current) {
1251
+ pressureRef.current.style.display = 'none';
1252
+ }
1253
+ if (tempRef.current) {
1254
+ tempRef.current.style.display = 'none';
1255
+ }
1256
+ if (dewpointRef.current) {
1257
+ dewpointRef.current.style.display = 'none';
1258
+ }
1259
+ if (windRef.current) {
1260
+ windRef.current.style.display = 'none';
1261
+ }
1262
+ };
1263
+ const canvasElement = canvasRef.current;
1264
+ if (canvasElement) {
1265
+ canvasElement.addEventListener('mouseleave', handleMouseLeave);
1266
+ }
1267
+ return () => {
1268
+ if (canvasElement) {
1269
+ canvasElement.removeEventListener('mouseleave', handleMouseLeave);
1270
+ }
1271
+ };
544
1272
  }, []);
545
- return jsx(Card, {
546
- children: jsx("svg", {
1273
+ useEffect(() => {
1274
+ // Reset the tooltips when filteredData changes
1275
+ if (pressureRef.current) {
1276
+ pressureRef.current.style.display = 'none';
1277
+ }
1278
+ if (tempRef.current) {
1279
+ tempRef.current.style.display = 'none';
1280
+ }
1281
+ if (dewpointRef.current) {
1282
+ dewpointRef.current.style.display = 'none';
1283
+ }
1284
+ if (windRef.current) {
1285
+ windRef.current.style.display = 'none';
1286
+ }
1287
+ }, [data]);
1288
+ return jsxs(Card, {
1289
+ ref: canvasRef,
1290
+ sx: {
1291
+ position: 'relative',
1292
+ paddingBottom: '20px'
1293
+ },
1294
+ children: [jsx("svg", {
547
1295
  ref: svgRef
548
- })
1296
+ }), jsx(Box, {
1297
+ ref: pressureRef,
1298
+ sx: {
1299
+ ...soundingsStyle.tooltip,
1300
+ ...soundingsStyle.pressure
1301
+ },
1302
+ "data-testid": "pressure-tooltip"
1303
+ }), jsx(Box, {
1304
+ ref: tempRef,
1305
+ sx: {
1306
+ ...soundingsStyle.tooltip,
1307
+ ...soundingsStyle.temperature
1308
+ },
1309
+ "data-testid": "temperature-tooltip"
1310
+ }), jsx(Box, {
1311
+ ref: dewpointRef,
1312
+ sx: {
1313
+ ...soundingsStyle.tooltip,
1314
+ ...soundingsStyle.dewPoint
1315
+ },
1316
+ "data-testid": "dewpoint-tooltip"
1317
+ }), jsx(Box, {
1318
+ ref: windRef,
1319
+ sx: {
1320
+ ...soundingsStyle.tooltip,
1321
+ ...soundingsStyle.wind
1322
+ },
1323
+ "data-testid": "wind-tooltip"
1324
+ })]
549
1325
  });
550
1326
  };
551
1327
 
@@ -562,104 +1338,720 @@ const Soundings = ({
562
1338
  * See the License for the specific language governing permissions and
563
1339
  * limitations under the License.
564
1340
  *
565
- * Copyright 2023 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
566
- * Copyright 2023 - Finnish Meteorological Institute (FMI)
567
- * Copyright 2024 - The Norwegian Meteorological Institute (MET Norway)
1341
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
1342
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
1343
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
568
1344
  * */
569
- const SOUNDINGS_NAMESPACE = 'soundings';
570
- const useSoundingsTranslation = () => useTranslation(SOUNDINGS_NAMESPACE);
1345
+ const BASE_URL = 'https://opendata.fmi.fi/edr';
1346
+ const getCollectionLocations = collectionId => fetch(`${BASE_URL}/collections/${collectionId}/locations`, {
1347
+ headers: getHeaders()
1348
+ }).then(handleResponse);
1349
+ const getCollectionInstance = collectionId => fetch(`${BASE_URL}/collections/${collectionId}/instances`, {
1350
+ headers: getHeaders()
1351
+ }).then(handleResponse);
1352
+ const getEdrCollectionForLocation = (collectionId, instanceId, locationId, parameters, datetime) => {
1353
+ const url = new URL(`${BASE_URL}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/locations/${locationId}`);
1354
+ url.searchParams.append('parameter-name', parameters.join(','));
1355
+ url.searchParams.append('datetime', datetime);
1356
+ url.searchParams.append('f', 'CoverageJSON');
1357
+ return fetch(url.toString(), {
1358
+ headers: getHeaders()
1359
+ }).then(handleResponse);
1360
+ };
1361
+ const getEdrCollectionForPosition = (collectionId, instanceId, coordinates, parameters, datetime) => {
1362
+ const url = new URL(`${BASE_URL}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/position`);
1363
+ url.searchParams.append('coords', `POINT(${coordinates[0]} ${coordinates[1]})`);
1364
+ url.searchParams.append('parameter-name', parameters.join(','));
1365
+ url.searchParams.append('datetime', datetime);
1366
+ url.searchParams.append('f', 'CoverageJSON');
1367
+ return fetch(url.toString(), {
1368
+ headers: getHeaders()
1369
+ }).then(handleResponse);
1370
+ };
571
1371
 
572
- const SoundingsSourceMenu = ({
573
- soundingsCollections,
574
- isOpen = false,
575
- handleClose
576
- }) => {
577
- const {
578
- t
579
- } = useSoundingsTranslation();
580
- const [currentSelection, setCurrentSelection] = React.useState({});
581
- React.useEffect(() => {
582
- const initialSelection = {};
583
- soundingsCollections?.forEach(collection => {
584
- const initialInstanceId = collection.instances.length > 0 ? collection.instances[0].id : undefined;
585
- initialSelection[collection.id] = {
586
- isChecked: false,
587
- selectedInstanceId: initialInstanceId
588
- };
589
- });
590
- setCurrentSelection(initialSelection);
591
- }, [soundingsCollections]);
592
- // TODO move to redux
593
- const handleSelectionChange = (collectionId, update) => {
594
- setCurrentSelection(prev => {
595
- const currentCollectionState = prev[collectionId] || {};
596
- return {
597
- ...prev,
598
- [collectionId]: {
599
- ...currentCollectionState,
600
- ...update
1372
+ /* *
1373
+ * Licensed under the Apache License, Version 2.0 (the "License");
1374
+ * you may not use this file except in compliance with the License.
1375
+ * You may obtain a copy of the License at
1376
+ *
1377
+ * http://www.apache.org/licenses/LICENSE-2.0
1378
+ *
1379
+ * Unless required by applicable law or agreed to in writing, software
1380
+ * distributed under the License is distributed on an "AS IS" BASIS,
1381
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1382
+ * See the License for the specific language governing permissions and
1383
+ * limitations under the License.
1384
+ *
1385
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
1386
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
1387
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1388
+ * */
1389
+ const epsilon = 0.622; // Ratio of molecular weights of water vapour and dry air
1390
+ const e0 = 611.2; // Reference vapor pressure [Pa]
1391
+ const aConstant = 17.67;
1392
+ const bConstant = 243.5; // Constants for water, b in Celsius
1393
+ const tKelvinOffset = 273.15; // To convert Celsius to Kelvin
1394
+ const gConstant = 461.5; // Gas constant ratio between water vapour and air [J/(K*kg)]
1395
+ const hConstant = 2.5e6; // Latent heat for water vaporization [J/kg]
1396
+ // Formula to use when dewpoint is not available
1397
+ // Known as Magnus Formula
1398
+ // - Calculate vapor pressure
1399
+ // - Use inverse of the Clausius-Clapeyron equation
1400
+ function dewpointFromSpecificHumidity(specificHumidity,
1401
+ // specific humidity [kg/kg]
1402
+ pressure) {
1403
+ // Step 1: Calculate vapor pressure (Pa)
1404
+ const vaporPressure = specificHumidity * pressure / (epsilon + (1 - epsilon) * specificHumidity);
1405
+ // Step 2: Calculate dew point in Celsius
1406
+ const lnRatio = Math.log(vaporPressure / e0);
1407
+ const dewpoint = bConstant * lnRatio / (aConstant - lnRatio);
1408
+ return dewpoint; // dew point in Celsius
1409
+ }
1410
+ // Formula to use when dewpoint is not available
1411
+ function dewpointFromRelativeHumidity(tCelsius,
1412
+ // air temperature [°C]
1413
+ relHumidity) {
1414
+ // Convert to Kelvin
1415
+ const tKelvin = tCelsius + tKelvinOffset;
1416
+ // Calculate dewpoint
1417
+ const dewpoint = tKelvin / (1 - tKelvin * Math.log(relHumidity * 0.01) * gConstant / hConstant);
1418
+ return dewpoint;
1419
+ }
1420
+ // Parsing and conversion function
1421
+ function parseCoverageCollection(response) {
1422
+ const features = [];
1423
+ const {
1424
+ t: tAxis,
1425
+ x: xAxis,
1426
+ y: yAxis,
1427
+ z: zAxis
1428
+ } = response.coverages[0]?.domain.axes || {};
1429
+ if (!tAxis?.values || !xAxis?.values || !yAxis?.values || !zAxis?.values) {
1430
+ return features;
1431
+ }
1432
+ response.coverages.forEach(coverage => {
1433
+ const {
1434
+ ranges
1435
+ } = coverage;
1436
+ const time = coverage.domain.axes.t?.values[0];
1437
+ const xCoord = coverage.domain.axes.x.values[0];
1438
+ const yCoord = coverage.domain.axes.y.values[0];
1439
+ const zValues = coverage.domain.axes.z.values;
1440
+ zValues.forEach((_, zIndex) => {
1441
+ const temp = parseFloat(ranges.temperature?.values[zIndex]);
1442
+ const windums = parseFloat(ranges.windums?.values[zIndex]);
1443
+ const windvms = parseFloat(ranges.windvms?.values[zIndex]);
1444
+ let dewpoint = parseFloat(ranges.dewpoint?.values[zIndex]);
1445
+ const relativeHumidity = parseFloat(ranges.relativehumidity?.values[zIndex]);
1446
+ const specificHumidity = parseFloat(ranges.specifichumidity?.values[zIndex]);
1447
+ let windSpeed = parseFloat(ranges.windSpeed?.values[zIndex]);
1448
+ // Fallback calculations
1449
+ if (Number.isNaN(windSpeed) && !Number.isNaN(windums) && !Number.isNaN(windvms)) {
1450
+ // Fallback calculation
1451
+ windSpeed = Math.sqrt(windums ** 2 + windvms ** 2);
1452
+ }
1453
+ let windDirection = parseFloat(ranges.windDirection?.values[zIndex]);
1454
+ if (Number.isNaN(windDirection) && !Number.isNaN(windums) && !Number.isNaN(windvms)) {
1455
+ // Fallback calculation
1456
+ windDirection = (Math.atan2(-windums, -windvms) * (180 / Math.PI) + 360) % 360;
1457
+ }
1458
+ const pressureFromRange = parseFloat(ranges.pressure?.values[zIndex]);
1459
+ const pressure = !Number.isNaN(pressureFromRange) ? pressureFromRange : parseFloat(zValues[zIndex]);
1460
+ if (dewpoint === null || Number.isNaN(dewpoint)) {
1461
+ // Use dewpoint fallback calculations if not present
1462
+ if (!Number.isNaN(specificHumidity)) {
1463
+ dewpoint = dewpointFromSpecificHumidity(specificHumidity / 1000,
1464
+ // from g/kg to kg/kg
1465
+ pressure * 100);
1466
+ } else if (!Number.isNaN(relativeHumidity)) {
1467
+ dewpoint = dewpointFromRelativeHumidity(temp, relativeHumidity);
601
1468
  }
602
- };
1469
+ }
1470
+ features.push({
1471
+ type: 'Feature',
1472
+ geometry: {
1473
+ type: 'Point',
1474
+ coordinates: [xCoord, yCoord]
1475
+ },
1476
+ properties: {
1477
+ time,
1478
+ temp,
1479
+ dewpoint,
1480
+ pressure,
1481
+ windSpeed,
1482
+ windDirection
1483
+ }
1484
+ });
603
1485
  });
1486
+ });
1487
+ return features;
1488
+ }
1489
+
1490
+ /* *
1491
+ * Licensed under the Apache License, Version 2.0 (the "License");
1492
+ * you may not use this file except in compliance with the License.
1493
+ * You may obtain a copy of the License at
1494
+ *
1495
+ * http://www.apache.org/licenses/LICENSE-2.0
1496
+ *
1497
+ * Unless required by applicable law or agreed to in writing, software
1498
+ * distributed under the License is distributed on an "AS IS" BASIS,
1499
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1500
+ * See the License for the specific language governing permissions and
1501
+ * limitations under the License.
1502
+ *
1503
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
1504
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
1505
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1506
+ * */
1507
+ const sortSoundingFeatures = data => {
1508
+ return data.sort((featureA, featureB) => {
1509
+ // Sort by date (ascending)
1510
+ if (featureA.properties.time !== featureB.properties.time) {
1511
+ return featureA.properties.time > featureB.properties.time ? 1 : -1;
1512
+ }
1513
+ // Sort by pressure (descending)
1514
+ return featureB.properties.pressure - featureA.properties.pressure;
1515
+ });
1516
+ };
1517
+ const constructQueryParams = (collectionId, instanceId, location, datetime, parameters) => {
1518
+ if (typeof location === 'string') {
1519
+ // LOCATION call
1520
+ return {
1521
+ queryKey: ['edr-soundings-for-location', collectionId, instanceId || '', location, parameters.join(), datetime],
1522
+ queryFn: async () => {
1523
+ // No collection or location selected, don't do EDR call but return empty array
1524
+ if (collectionId === undefined || location === '') {
1525
+ return [];
1526
+ }
1527
+ try {
1528
+ const rawData = await getEdrCollectionForLocation(collectionId, instanceId, location, parameters, datetime);
1529
+ const parsedData = parseCoverageCollection(rawData);
1530
+ return sortSoundingFeatures(parsedData);
1531
+ } catch (err) {
1532
+ throw new Error('Error fetching EDR data collection by location');
1533
+ }
1534
+ }
1535
+ };
1536
+ }
1537
+ // POSITION call
1538
+ return {
1539
+ queryKey: ['edr-soundings-for-position', collectionId, location.join(), parameters.join(), datetime],
1540
+ queryFn: async () => {
1541
+ // No collection or position selected, don't do EDR call but return empty array
1542
+ if (collectionId === undefined || location === undefined) {
1543
+ return [];
1544
+ }
1545
+ try {
1546
+ const rawData = await getEdrCollectionForPosition(collectionId, instanceId, location, parameters, datetime);
1547
+ const parsedData = parseCoverageCollection(rawData);
1548
+ return sortSoundingFeatures(parsedData);
1549
+ } catch (err) {
1550
+ throw new Error('Error fetching EDR data collection by position');
1551
+ }
1552
+ }
604
1553
  };
605
- const getDisplayValue = (collection, instanceId) => {
606
- if (!instanceId) {
607
- return jsx(Fragment, {
608
- children: t('noInstancesAvailable')
1554
+ };
1555
+ const useFakeTime = (collectionId, instanceId) => {
1556
+ return useQuery({
1557
+ queryKey: ['temporary-instance-call', collectionId, instanceId],
1558
+ queryFn: async () => {
1559
+ return fetch(`https://opendata.fmi.fi/edr/collections/${collectionId}/instances/${instanceId}`, {
1560
+ headers: getHeaders()
1561
+ }).then(handleResponse).then(data => collectionId === undefined || data === undefined ? '' : data.extent.temporal.interval[0][0]);
1562
+ },
1563
+ enabled: !!collectionId && !!instanceId
1564
+ });
1565
+ };
1566
+ const parameters = ['temperature', 'WindDirection', 'WindSpeedMS', 'windUMS', 'windVMS', 'Pressure', 'DewPoint', 'humidity', 'specifichumidity'];
1567
+ const useMultipleSources = (sources, locations, datetime) => {
1568
+ // TODO: Remove once we pass in the datetime coming from the timeslider!
1569
+ const {
1570
+ data: date0
1571
+ } = useFakeTime(sources[0]?.collectionId, sources[0]?.instanceId);
1572
+ const {
1573
+ data: date1
1574
+ } = useFakeTime(sources[1]?.collectionId, sources[1]?.instanceId);
1575
+ const {
1576
+ data: date2
1577
+ } = useFakeTime(sources[2]?.collectionId, sources[2]?.instanceId);
1578
+ const dates = useMemo(() => [date0, date1, date2], [date0, date1, date2]);
1579
+ const queries = useMemo(() => sources.map(({
1580
+ collectionId,
1581
+ instanceId
1582
+ }, index) => {
1583
+ const queryParams = constructQueryParams(collectionId, instanceId, locations[collectionId] || '', dates[index] || '', parameters);
1584
+ return queryParams;
1585
+ }), [dates, datetime, locations, sources]);
1586
+ return useQueries({
1587
+ queries,
1588
+ combine: queryResults => {
1589
+ const data = queryResults.map(result => {
1590
+ return result.data || [];
609
1591
  });
1592
+ const isPending = queryResults.some(result => result.isPending);
1593
+ const error = queryResults.find(result => result?.error)?.error;
1594
+ return {
1595
+ data,
1596
+ isPending,
1597
+ error
1598
+ };
610
1599
  }
611
- const instanceIndex = collection.instances.findIndex(i => i.id === instanceId);
612
- if (instanceIndex === 0) {
613
- return jsxs(Box, {
614
- display: "flex",
615
- children: [jsx(Box, {
616
- sx: {
617
- minWidth: 200
618
- },
619
- children: collection.name
620
- }), jsx(Box, {
621
- sx: {
622
- textOverflow: 'ellipsis',
623
- overflow: 'hidden'
624
- },
625
- children: t('latest')
626
- })]
1600
+ });
1601
+ };
1602
+ const useGetCollectionsInstances = (collections, isDialogOpen) => {
1603
+ const collectionIds = useMemo(() => collections?.map(collection => collection.id) || [], [collections]);
1604
+ const queries = useMemo(() => collectionIds.map(id => {
1605
+ return {
1606
+ queryKey: ['edr-soundings-instances', id],
1607
+ queryFn: async () => {
1608
+ const data = await getCollectionInstance(id);
1609
+ return data;
1610
+ },
1611
+ enabled: isDialogOpen
1612
+ };
1613
+ }), [collectionIds, isDialogOpen]);
1614
+ const results = useQueries({
1615
+ queries,
1616
+ combine: queryResults => {
1617
+ const data = queryResults.map(result => {
1618
+ const instances = result.data?.instances.sort((featureA, featureB) => featureB.id.localeCompare(featureA.id)) || []; // Sort to get latest first
1619
+ return instances;
627
1620
  });
1621
+ const isPending = queryResults.some(result => result.isPending);
1622
+ const error = queryResults.find(result => result?.error)?.error;
1623
+ return {
1624
+ data,
1625
+ isPending,
1626
+ error
1627
+ };
628
1628
  }
629
- return jsxs(Box, {
630
- display: "flex",
631
- children: [jsx(Box, {
632
- sx: {
633
- minWidth: 200
634
- },
635
- children: collection.name
636
- }), jsx(Box, {
1629
+ });
1630
+ const dataWithCollectionInfo = collections?.map((collection, index) => {
1631
+ return {
1632
+ ...collection,
1633
+ instances: results.data[index] || []
1634
+ };
1635
+ });
1636
+ return {
1637
+ ...results,
1638
+ data: dataWithCollectionInfo
1639
+ };
1640
+ };
1641
+
1642
+ /* *
1643
+ * Licensed under the Apache License, Version 2.0 (the "License");
1644
+ * you may not use this file except in compliance with the License.
1645
+ * You may obtain a copy of the License at
1646
+ *
1647
+ * http://www.apache.org/licenses/LICENSE-2.0
1648
+ *
1649
+ * Unless required by applicable law or agreed to in writing, software
1650
+ * distributed under the License is distributed on an "AS IS" BASIS,
1651
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1652
+ * See the License for the specific language governing permissions and
1653
+ * limitations under the License.
1654
+ *
1655
+ * Copyright 2023 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
1656
+ * Copyright 2023 - Finnish Meteorological Institute (FMI)
1657
+ * Copyright 2024 - The Norwegian Meteorological Institute (MET Norway)
1658
+ * */
1659
+ const SOUNDINGS_NAMESPACE = 'soundings';
1660
+ const initSoundingsI18n = () => {
1661
+ safelyInitI18nForTestsAndStories(i18n, SOUNDINGS_NAMESPACE, [{
1662
+ ns: SOUNDINGS_NAMESPACE,
1663
+ translations: soundingsTranslations
1664
+ }, {
1665
+ ns: TIMESLIDER_NAMESPACE,
1666
+ translations: timesliderTranslations
1667
+ }, {
1668
+ ns: SHARED_NAMESPACE,
1669
+ translations: sharedTranslations
1670
+ }]);
1671
+ };
1672
+ const ns = [SOUNDINGS_NAMESPACE, TIMESLIDER_NAMESPACE, SHARED_NAMESPACE];
1673
+ const translateKeyOutsideComponents = (key, params = undefined) => i18n.t(key, {
1674
+ ns,
1675
+ ...params
1676
+ });
1677
+ const useSoundingsTranslation = () => useTranslation(ns);
1678
+
1679
+ const SoundingsTimeSlider = ({
1680
+ timesteps,
1681
+ selectedTime,
1682
+ updateSelectedTime
1683
+ }) => {
1684
+ const {
1685
+ i18n
1686
+ } = useSoundingsTranslation();
1687
+ const marks = timesteps.filter(date => dateUtils.isValidIsoDateString(date)).map(date => ({
1688
+ value: dateUtils.unix(dateUtils.getUtcFromString(date)),
1689
+ label: dateUtils.dateToString(dateUtils.getUtcFromString(date), 'iii d. HH:mm', true, i18n.language)
1690
+ }));
1691
+ const onStepBackward = React.useCallback(() => {
1692
+ const currentIndex = timesteps.indexOf(selectedTime);
1693
+ const precedingIndex = Math.max(currentIndex - 1, 0);
1694
+ updateSelectedTime(timesteps[precedingIndex]);
1695
+ }, [selectedTime, updateSelectedTime, timesteps]);
1696
+ const onStepForward = React.useCallback(() => {
1697
+ const currentIndex = timesteps.indexOf(selectedTime);
1698
+ const followingIndex = Math.min(currentIndex + 1, timesteps.length - 1);
1699
+ updateSelectedTime(timesteps[followingIndex]);
1700
+ }, [selectedTime, updateSelectedTime, timesteps]);
1701
+ return jsxs("div", {
1702
+ style: {
1703
+ display: 'flex',
1704
+ marginTop: '17px'
1705
+ },
1706
+ children: [jsxs("div", {
1707
+ style: {
1708
+ minWidth: '60px',
1709
+ marginTop: '2px'
1710
+ },
1711
+ children: [jsx(BackwardForwardStepButton, {
1712
+ isForwardStep: false,
1713
+ onClickBFButton: onStepBackward,
1714
+ isDisabled: marks.length === 0
1715
+ }), jsx(BackwardForwardStepButton, {
1716
+ isForwardStep: true,
1717
+ onClickBFButton: onStepForward,
1718
+ isDisabled: marks.length === 0
1719
+ })]
1720
+ }), marks.length === 0 ? jsx(CustomSlider, {
1721
+ min: 0,
1722
+ max: 1,
1723
+ value: 0.5,
1724
+ orientation: "horizontal",
1725
+ disabled: true
1726
+ }) : jsx(CustomSlider, {
1727
+ orientation: "horizontal",
1728
+ valueLabelDisplay: "auto",
1729
+ valueLabelFormat: value => dateUtils.dateToString(dateUtils.fromUnix(value), "yyyy iii dd MMM HH:mm 'UTC'", true, i18n.language),
1730
+ marks: marks,
1731
+ step: null,
1732
+ min: marks[0].value,
1733
+ max: marks[marks.length - 1].value,
1734
+ value: dateUtils.unix(dateUtils.getUtcFromString(selectedTime)),
1735
+ onChange: (_, value) => {
1736
+ updateSelectedTime(dateUtils.fromUnix(value).toISOString());
1737
+ },
1738
+ extraStyles: {
1739
+ margin: '0px 20px 0px 20px',
1740
+ '.MuiSlider-markLabel': {
1741
+ rotate: '-45deg',
1742
+ translate: '-41%'
1743
+ }
1744
+ }
1745
+ })]
1746
+ });
1747
+ };
1748
+
1749
+ /* *
1750
+ * Licensed under the Apache License, Version 2.0 (the "License");
1751
+ * you may not use this file except in compliance with the License.
1752
+ * You may obtain a copy of the License at
1753
+ *
1754
+ * http://www.apache.org/licenses/LICENSE-2.0
1755
+ *
1756
+ * Unless required by applicable law or agreed to in writing, software
1757
+ * distributed under the License is distributed on an "AS IS" BASIS,
1758
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1759
+ * See the License for the specific language governing permissions and
1760
+ * limitations under the License.
1761
+ *
1762
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
1763
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
1764
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1765
+ * */
1766
+ const initialSoundingsState = {
1767
+ selectedSources: {},
1768
+ selectedLocations: {},
1769
+ selectedTime: new Date().toISOString()
1770
+ };
1771
+ const slice = createSlice({
1772
+ initialState: initialSoundingsState,
1773
+ name: 'soundings',
1774
+ reducers: {
1775
+ updateSelectedSource: (draft, payloadAction) => {
1776
+ const {
1777
+ sourceId,
1778
+ value
1779
+ } = payloadAction.payload;
1780
+ draft.selectedSources[sourceId] = {
1781
+ ...draft.selectedSources[sourceId],
1782
+ ...value
1783
+ };
1784
+ },
1785
+ updateSelectedLocation: (draft, payloadAction) => {
1786
+ const {
1787
+ sourceId,
1788
+ locationId
1789
+ } = payloadAction.payload;
1790
+ draft.selectedLocations[sourceId] = locationId;
1791
+ },
1792
+ updateSelectedTime: (draft, payloadAction) => {
1793
+ const {
1794
+ selectedTime
1795
+ } = payloadAction.payload;
1796
+ draft.selectedTime = selectedTime;
1797
+ }
1798
+ }
1799
+ });
1800
+ const {
1801
+ reducer: soundingsReducer,
1802
+ actions: soundingsActions
1803
+ } = slice;
1804
+
1805
+ /* *
1806
+ * Licensed under the Apache License, Version 2.0 (the "License");
1807
+ * you may not use this file except in compliance with the License.
1808
+ * You may obtain a copy of the License at
1809
+ *
1810
+ * http://www.apache.org/licenses/LICENSE-2.0
1811
+ *
1812
+ * Unless required by applicable law or agreed to in writing, software
1813
+ * distributed under the License is distributed on an "AS IS" BASIS,
1814
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1815
+ * See the License for the specific language governing permissions and
1816
+ * limitations under the License.
1817
+ *
1818
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
1819
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
1820
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1821
+ * */
1822
+ const getSoundingsStore = store => store?.soundings || initialSoundingsState;
1823
+ const getSourceSelections = createSelector(getSoundingsStore, store => {
1824
+ return store.selectedSources;
1825
+ }, selectorMemoizationOptions);
1826
+ const getLocationSelections = createSelector(getSoundingsStore, store => store.selectedLocations, selectorMemoizationOptions);
1827
+ const getSelectedTime = createSelector(getSoundingsStore, store => store.selectedTime, selectorMemoizationOptions);
1828
+ const getSelectedSources = createSelector(getSoundingsStore, store => {
1829
+ const sources = store.selectedSources;
1830
+ const activeSources = Object.keys(sources).reduce((list, key) => {
1831
+ const value = sources[key];
1832
+ if (value.isChecked === true) {
1833
+ return list.concat({
1834
+ collectionId: key,
1835
+ instanceId: value.instanceId
1836
+ });
1837
+ }
1838
+ return list;
1839
+ }, []);
1840
+ return activeSources;
1841
+ }, selectorMemoizationOptions);
1842
+ const getSelectedSourcesAmount = createSelector(getSelectedSources, activeSources => {
1843
+ return activeSources.length;
1844
+ }, selectorMemoizationOptions);
1845
+
1846
+ const SoundingsTimeSliderConnect = ({
1847
+ timesteps
1848
+ }) => {
1849
+ const selectedTime = useSelector(getSelectedTime);
1850
+ const dispatch = useDispatch();
1851
+ const updateSelectedTime = React.useCallback(selectedTime => {
1852
+ dispatch(soundingsActions.updateSelectedTime({
1853
+ selectedTime
1854
+ }));
1855
+ }, [dispatch]);
1856
+ return jsx(SoundingsTimeSlider, {
1857
+ timesteps: timesteps,
1858
+ selectedTime: selectedTime,
1859
+ updateSelectedTime: updateSelectedTime
1860
+ });
1861
+ };
1862
+
1863
+ var MenuOptions;
1864
+ (function (MenuOptions) {
1865
+ MenuOptions["sources"] = "sources";
1866
+ MenuOptions["locations"] = "locations";
1867
+ })(MenuOptions || (MenuOptions = {}));
1868
+ const buttonStyle = {
1869
+ height: '10px !important',
1870
+ width: '130px'
1871
+ };
1872
+ const SoundingsMenuBar = ({
1873
+ handleClick,
1874
+ isOpen,
1875
+ selectedSourcesAmount = 0
1876
+ }) => {
1877
+ const {
1878
+ t
1879
+ } = useSoundingsTranslation();
1880
+ return jsxs(Stack, {
1881
+ direction: "row",
1882
+ spacing: 1,
1883
+ sx: {
1884
+ marginBottom: '4px'
1885
+ },
1886
+ children: [jsxs(CustomToggleButton, {
1887
+ variant: "tool",
1888
+ selected: isOpen[MenuOptions.sources],
1889
+ "data-testid": "soundingsSourcesButton",
1890
+ onClick: () => handleClick(MenuOptions.sources),
1891
+ size: "small",
1892
+ sx: buttonStyle,
1893
+ children: [jsx(Sources, {
637
1894
  sx: {
638
- textOverflow: 'ellipsis',
639
- overflow: 'hidden'
640
- },
641
- children: dateUtils.dateToIsoString(dateUtils.isoStringToDate(instanceId))
1895
+ marginRight: '4px!important'
1896
+ }
1897
+ }), selectedSourcesAmount ? `${t('sources')} (${selectedSourcesAmount})` : t('sources')]
1898
+ }), jsx(CustomToggleButton, {
1899
+ variant: "tool",
1900
+ "data-testid": "soundingsLocationsButton",
1901
+ selected: isOpen[MenuOptions.locations],
1902
+ onClick: () => handleClick(MenuOptions.locations),
1903
+ size: "small",
1904
+ sx: buttonStyle,
1905
+ children: t('locations')
1906
+ })]
1907
+ });
1908
+ };
1909
+
1910
+ const MenuBarConnect = ({
1911
+ viewId,
1912
+ source
1913
+ }) => {
1914
+ const dispatch = useDispatch();
1915
+ const currentActiveMapIdSources = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsSources));
1916
+ const currentActiveMapIdLocations = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsLocations));
1917
+ const isSourcesOpenInStore = useSelector(store => uiSelectors.getisDialogOpen(store, uiTypes.DialogTypes.SoundingsSources));
1918
+ const isLocationsOpenInStore = useSelector(store => uiSelectors.getisDialogOpen(store, uiTypes.DialogTypes.SoundingsLocations));
1919
+ const sourcesAmount = useSelector(getSelectedSourcesAmount);
1920
+ const dispatchActiveMapId = React.useCallback((dialogType, isOpen, activeMapId) => void dispatch(uiActions.setActiveMapIdForDialog({
1921
+ type: dialogType,
1922
+ mapId: viewId,
1923
+ setOpen: activeMapId !== viewId ? true : !isOpen,
1924
+ source
1925
+ })), [dispatch, viewId, source]);
1926
+ const handleClick = React.useCallback(menuType => {
1927
+ switch (menuType) {
1928
+ case MenuOptions.sources:
1929
+ dispatchActiveMapId(uiTypes.DialogTypes.SoundingsSources, isSourcesOpenInStore, currentActiveMapIdSources);
1930
+ break;
1931
+ case MenuOptions.locations:
1932
+ dispatchActiveMapId(uiTypes.DialogTypes.SoundingsLocations, isLocationsOpenInStore, currentActiveMapIdLocations);
1933
+ break;
1934
+ }
1935
+ }, [dispatchActiveMapId, isSourcesOpenInStore, currentActiveMapIdSources, isLocationsOpenInStore, currentActiveMapIdLocations]);
1936
+ const isOpen = {
1937
+ [MenuOptions.sources]: currentActiveMapIdSources === viewId && isSourcesOpenInStore,
1938
+ [MenuOptions.locations]: currentActiveMapIdLocations === viewId && isLocationsOpenInStore
1939
+ };
1940
+ return jsx(SoundingsMenuBar, {
1941
+ isOpen: isOpen,
1942
+ handleClick: handleClick,
1943
+ selectedSourcesAmount: sourcesAmount
1944
+ });
1945
+ };
1946
+
1947
+ const SoundingsConnect = ({
1948
+ viewId
1949
+ }) => {
1950
+ // TODO: use in follow-up: https://gitlab.com/opengeoweb/opengeoweb/-/issues/5991
1951
+ // const selectedTime = useSelector(getSelectedTime);
1952
+ const sourceSelections = useSelector(getSelectedSources);
1953
+ const locationSelections = useSelector(getLocationSelections);
1954
+ const {
1955
+ error,
1956
+ data,
1957
+ isPending
1958
+ } = useMultipleSources(sourceSelections, locationSelections);
1959
+ return jsxs(Fragment, {
1960
+ children: [error && jsx(AlertBanner, {
1961
+ title: error.message,
1962
+ shouldClose: true
1963
+ }), isPending && jsx(LinearProgress, {
1964
+ color: "secondary",
1965
+ sx: {
1966
+ position: 'absolute',
1967
+ width: '100%'
1968
+ }
1969
+ }), jsxs(Box, {
1970
+ sx: {
1971
+ padding: 2,
1972
+ overflowY: 'scroll',
1973
+ height: '100%'
1974
+ },
1975
+ children: [jsx(Box, {
1976
+ paddingLeft: 5,
1977
+ children: jsx(MenuBarConnect, {
1978
+ viewId: viewId
1979
+ })
1980
+ }), jsx(Soundings, {
1981
+ data: data,
1982
+ svgDimensions: {
1983
+ width: 600,
1984
+ height: 600,
1985
+ margin: {
1986
+ bottom: 0,
1987
+ left: 0,
1988
+ right: 0,
1989
+ top: 0
1990
+ }
1991
+ }
1992
+ }), jsx(SoundingsTimeSliderConnect
1993
+ // TODO: use real timesteps https://gitlab.com/opengeoweb/opengeoweb/-/issues/5991
1994
+ , {
1995
+ // TODO: use real timesteps https://gitlab.com/opengeoweb/opengeoweb/-/issues/5991
1996
+ timesteps: []
642
1997
  })]
643
- });
1998
+ })]
1999
+ });
2000
+ };
2001
+
2002
+ const SoundingsSourceMenu = ({
2003
+ soundingsCollections,
2004
+ isOpen = false,
2005
+ handleClose,
2006
+ updateCollection,
2007
+ sourceSelections,
2008
+ onMouseDown = () => {},
2009
+ order = 0,
2010
+ isLoading = false,
2011
+ error
2012
+ }) => {
2013
+ const {
2014
+ t
2015
+ } = useSoundingsTranslation();
2016
+ const getDisplayValue = instanceId => {
2017
+ const instanceAsDate = dateUtils.isoStringToDate(instanceId);
2018
+ return instanceAsDate ? dateUtils.dateToIsoString(instanceAsDate) : instanceId;
644
2019
  };
645
- return jsx(ToolContainerDraggable, {
2020
+ return jsxs(ToolContainerDraggable, {
646
2021
  startSize: {
647
2022
  width: 320
648
2023
  },
2024
+ startPosition: {
2025
+ top: 40,
2026
+ left: 50
2027
+ },
649
2028
  minWidth: 300,
650
2029
  title: t('sources'),
651
2030
  isOpen: isOpen,
652
- onClose: () => handleClose(),
2031
+ onClose: handleClose,
653
2032
  bounds: "parent",
654
- children: jsx(List, {
2033
+ onMouseDown: onMouseDown,
2034
+ order: order,
2035
+ className: "source-menu",
2036
+ children: [isLoading && jsx(LinearProgress, {
2037
+ sx: {
2038
+ width: '100%',
2039
+ position: 'absolute'
2040
+ }
2041
+ }), error && jsx(AlertBanner, {
2042
+ title: error.message,
2043
+ shouldClose: true
2044
+ }), jsx(List, {
655
2045
  sx: {
656
2046
  pr: 1
657
2047
  },
658
2048
  children: soundingsCollections && soundingsCollections.length > 0 ? soundingsCollections.map(collection => {
659
- const currentCollectionState = currentSelection[collection.id];
660
- const isChecked = currentCollectionState?.isChecked ?? false;
661
- const selectedInstanceId = currentCollectionState?.selectedInstanceId ?? ''; // Use empty string for Select value if undefined
2049
+ const {
2050
+ isChecked,
2051
+ instanceId
2052
+ } = sourceSelections[collection.id] || {};
662
2053
  const hasInstances = collection.instances.length > 0;
2054
+ const selectedInstanceId = instanceId || collection.instances[0]?.id || undefined;
663
2055
  return jsxs(ListItem, {
664
2056
  disablePadding: true,
665
2057
  children: [jsx(ListItemIcon, {
@@ -667,36 +2059,54 @@ const SoundingsSourceMenu = ({
667
2059
  minWidth: 42
668
2060
  },
669
2061
  children: jsx(Checkbox, {
670
- checked: isChecked,
671
- onChange: event => handleSelectionChange(collection.id, {
672
- isChecked: event.target.checked
2062
+ checked: !!isChecked,
2063
+ onChange: event => updateCollection(collection.id, {
2064
+ isChecked: event.target.checked,
2065
+ instanceId: selectedInstanceId
673
2066
  }),
674
2067
  disableRipple: true
675
2068
  })
676
2069
  }), jsxs(StyledSelect, {
677
2070
  isEnabled: true,
2071
+ additionalStyling: {
2072
+ width: 'calc(100% - 42px)'
2073
+ },
678
2074
  skipLocalStyling: false,
679
- value: selectedInstanceId,
680
- onChange: event => handleSelectionChange(collection.id, {
681
- selectedInstanceId: event.target.value
2075
+ value: selectedInstanceId || t('noInstancesAvailable'),
2076
+ onChange: event => updateCollection(collection.id, {
2077
+ instanceId: event.target.value
682
2078
  }),
683
2079
  renderValue: value => jsx(Box, {
684
2080
  sx: {
685
2081
  textOverflow: 'ellipsis',
686
2082
  overflow: 'hidden'
687
2083
  },
688
- children: getDisplayValue(collection, value)
2084
+ children: jsxs(Box, {
2085
+ display: "flex",
2086
+ children: [jsx(Box, {
2087
+ sx: {
2088
+ minWidth: 200
2089
+ },
2090
+ children: collection.name
2091
+ }), jsx(Box, {
2092
+ sx: {
2093
+ textOverflow: 'ellipsis',
2094
+ overflow: 'hidden'
2095
+ },
2096
+ children: getDisplayValue(value)
2097
+ })]
2098
+ })
689
2099
  }),
690
2100
  children: [!hasInstances && jsx(MenuItem, {
691
- value: "",
2101
+ value: t('noInstancesAvailable'),
692
2102
  disabled: true,
693
2103
  sx: {
694
2104
  fontSize: 12
695
2105
  },
696
2106
  children: t('noInstancesAvailable')
697
- }), collection.instances.map((instance, index) => jsx(MenuItem, {
2107
+ }), collection.instances.map(instance => jsx(MenuItem, {
698
2108
  value: instance.id,
699
- children: index === 0 ? t('latest') : dateUtils.dateToIsoString(dateUtils.isoStringToDate(instance.id)) || instance.id
2109
+ children: getDisplayValue(instance.id)
700
2110
  }, instance.id))]
701
2111
  })]
702
2112
  }, collection.id);
@@ -708,125 +2118,168 @@ const SoundingsSourceMenu = ({
708
2118
  }
709
2119
  })
710
2120
  })
711
- })
2121
+ })]
712
2122
  });
713
2123
  };
714
2124
 
715
- /* *
716
- * Licensed under the Apache License, Version 2.0 (the "License");
717
- * you may not use this file except in compliance with the License.
718
- * You may obtain a copy of the License at
719
- *
720
- * http://www.apache.org/licenses/LICENSE-2.0
721
- *
722
- * Unless required by applicable law or agreed to in writing, software
723
- * distributed under the License is distributed on an "AS IS" BASIS,
724
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
725
- * See the License for the specific language governing permissions and
726
- * limitations under the License.
727
- *
728
- * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
729
- * Copyright 2025 - Finnish Meteorological Institute (FMI)
730
- * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
731
- * */
732
- const BASE_URL = 'https://opendata.fmi.fi/edr';
733
- const getCollectionLocations = collectionId => fetch(`${BASE_URL}/collections/${collectionId}/locations`, {
734
- headers: getHeaders()
735
- }).then(handleResponse);
736
- const getCollectionInstance = (baseURL, collectionId) => fetch(`${baseURL}/collections/${collectionId}/instances`, {
737
- headers: getHeaders()
738
- }).then(handleResponse);
739
-
740
2125
  const SoundingsSourceDialogConnect = ({
741
2126
  soundingsCollections
742
2127
  }) => {
743
- const [soundingsCollectionsWithInstances, setSoundingsCollectionsWithInstances] = React.useState();
2128
+ const dispatch = useDispatch();
744
2129
  const {
745
2130
  isDialogOpen,
746
- onCloseDialog
2131
+ onCloseDialog,
2132
+ dialogOrder,
2133
+ setDialogOrder
747
2134
  } = useSetupDialog(uiTypes.DialogTypes.SoundingsSources);
748
- React.useEffect(() => {
749
- if (soundingsCollections && isDialogOpen) {
750
- try {
751
- void Promise.all(soundingsCollections.map(async collection => {
752
- const data = await getCollectionInstance(collection.url, collection.id);
753
- return {
754
- ...collection,
755
- instances: data.instances.reverse()
756
- }; // Reverse to get latest first
757
- })).then(results => setSoundingsCollectionsWithInstances(results));
758
- } catch (error) {
759
- console.error('Error fetching soundings instances:', error);
760
- }
761
- }
762
- }, [soundingsCollections, isDialogOpen]);
2135
+ const updateCollection = React.useCallback((sourceId, value) => {
2136
+ dispatch(soundingsActions.updateSelectedSource({
2137
+ sourceId,
2138
+ value
2139
+ }));
2140
+ }, [dispatch]);
2141
+ const sourceSelections = useSelector(getSourceSelections);
2142
+ const {
2143
+ data,
2144
+ isPending,
2145
+ error
2146
+ } = useGetCollectionsInstances(soundingsCollections, isDialogOpen);
763
2147
  return jsx(SoundingsSourceMenu, {
764
- soundingsCollections: soundingsCollectionsWithInstances,
2148
+ soundingsCollections: data,
765
2149
  isOpen: isDialogOpen,
766
- handleClose: () => onCloseDialog()
2150
+ updateCollection: updateCollection,
2151
+ sourceSelections: sourceSelections,
2152
+ handleClose: onCloseDialog,
2153
+ onMouseDown: setDialogOrder,
2154
+ order: dialogOrder,
2155
+ isLoading: isPending,
2156
+ error: error
767
2157
  });
768
2158
  };
769
2159
 
770
2160
  const LocationsMenu = ({
771
2161
  services,
2162
+ selectedLocations,
772
2163
  isOpen,
773
- onClose
2164
+ onClose,
2165
+ updateSelectedLocation,
2166
+ onMouseDown = () => {},
2167
+ order = 0
774
2168
  }) => {
775
- const [collapseMap, setCollapseMap] = useState({});
2169
+ const [searchFilter, setSearchFilter] = useState('');
776
2170
  const {
777
2171
  t
778
2172
  } = useSoundingsTranslation();
2173
+ const filteredResults = useMemo(() => {
2174
+ return services?.map(service => {
2175
+ const filteredFeatures = service.features ? service.features.filter(feature => {
2176
+ return feature.properties?.name.toLowerCase().includes(searchFilter.toLowerCase());
2177
+ }) : [];
2178
+ return {
2179
+ ...service,
2180
+ features: filteredFeatures
2181
+ };
2182
+ });
2183
+ }, [services, searchFilter]);
2184
+ const isChecked = (sourceId, locationId) => selectedLocations[sourceId] === locationId;
779
2185
  return jsx(ToolContainerDraggable, {
780
2186
  startSize: {
781
2187
  width: 320,
782
2188
  height: 500
783
2189
  },
2190
+ minWidth: 264,
784
2191
  title: t('locations'),
785
- headerSize: "medium",
786
2192
  isOpen: isOpen,
787
- onClose: () => onClose(),
2193
+ startPosition: {
2194
+ top: 60,
2195
+ left: 200
2196
+ },
2197
+ onClose: onClose,
788
2198
  bounds: "parent",
789
- children: jsx(List, {
790
- disablePadding: true,
2199
+ onMouseDown: onMouseDown,
2200
+ order: order,
2201
+ children: services.length === 0 ? jsx(Typography, {
791
2202
  sx: {
792
- maxHeight: '100%',
793
- overflowY: 'auto'
2203
+ margin: 1
794
2204
  },
795
- children: services.map(service => jsxs(Fragment$1, {
796
- children: [jsx(ListSubheader, {
797
- disableGutters: true,
2205
+ children: t('locations-menu-warning')
2206
+ }) : jsxs(Grid, {
2207
+ container: true,
2208
+ children: [jsx(Box, {
2209
+ sx: {
2210
+ width: '100%',
2211
+ padding: 1,
2212
+ position: 'sticky',
2213
+ top: 0,
2214
+ zIndex: 1,
2215
+ backgroundColor: 'geowebColors.background.surfaceApp'
2216
+ },
2217
+ children: jsx(SearchField, {
2218
+ searchFilter: searchFilter,
2219
+ setSearchFilter: setSearchFilter,
2220
+ small: true
2221
+ })
2222
+ }), filteredResults.map(service => jsx(Fragment$1, {
2223
+ children: jsx(CustomAccordion, {
2224
+ title: service.name,
798
2225
  sx: {
799
- borderBottom: '1px solid'
800
- },
801
- children: jsxs(ListItemButton, {
802
- onClick: () => setCollapseMap({
803
- ...collapseMap,
804
- [service.id]: !collapseMap[service.id]
805
- }),
806
- children: [jsx(ListItemText, {
807
- children: service.id
808
- }), jsx(ListItemIcon, {
809
- children: jsx(ChevronDown, {})
810
- })]
811
- })
812
- }), jsx(Collapse, {
813
- in: collapseMap[service.id],
814
- children: jsx(List, {
815
- component: "div",
816
- disablePadding: true,
817
- sx: {
818
- overflowY: 'auto',
819
- overflow: 'hidden'
2226
+ width: '100%',
2227
+ margin: '0 8px 2px 8px',
2228
+ '.MuiAccordionDetails-root': {
2229
+ padding: 0
820
2230
  },
821
- children: service.features?.map(location => jsx(ListItem, {
822
- sx: {
823
- pl: 4
824
- },
825
- children: location.properties.name
826
- }, `${service.id}-${location.id}`))
2231
+ '.MuiAccordionSummary-root': {
2232
+ minHeight: '40px'
2233
+ },
2234
+ '.MuiAccordionSummary-content': {
2235
+ paddingLeft: 3
2236
+ }
2237
+ },
2238
+ amountOfItems: service.features.length,
2239
+ defaultExpanded: false,
2240
+ children: jsx(FixedSizeList, {
2241
+ itemSize: 40,
2242
+ height: 320,
2243
+ width: "100%",
2244
+ itemCount: service.features.length,
2245
+ children: ({
2246
+ index,
2247
+ style
2248
+ }) => {
2249
+ const location = service.features[index];
2250
+ return jsx("div", {
2251
+ style: style,
2252
+ children: jsx(ListItem, {
2253
+ disablePadding: true,
2254
+ disableGutters: true,
2255
+ children: jsxs(ListItemButton, {
2256
+ onClick: () => {
2257
+ updateSelectedLocation(service.id, isChecked(service.id, location.id) ? undefined : location.id);
2258
+ },
2259
+ dense: true,
2260
+ children: [jsx(ListItemIcon, {
2261
+ children: jsx(Checkbox, {
2262
+ edge: "start",
2263
+ checked: isChecked(service.id, location.id),
2264
+ tabIndex: -1,
2265
+ disableRipple: true,
2266
+ slotProps: {
2267
+ input: {
2268
+ 'aria-labelledby': location.id
2269
+ }
2270
+ }
2271
+ })
2272
+ }), jsx(ListItemText, {
2273
+ id: location.id,
2274
+ primary: location.properties.name
2275
+ })]
2276
+ })
2277
+ })
2278
+ }, `${service.id}-${location.id}`);
2279
+ }
827
2280
  })
828
- })]
829
- }, service.id))
2281
+ })
2282
+ }, service.id))]
830
2283
  })
831
2284
  });
832
2285
  };
@@ -834,17 +2287,24 @@ const LocationsMenu = ({
834
2287
  const LocationsMenuConnect = ({
835
2288
  soundingsCollections
836
2289
  }) => {
2290
+ const dispatch = useDispatch();
837
2291
  const {
838
2292
  isDialogOpen,
839
- onCloseDialog
840
- } = useSetupDialog(uiTypes.DialogTypes.soundingsLocations);
2293
+ onCloseDialog,
2294
+ dialogOrder,
2295
+ setDialogOrder
2296
+ } = useSetupDialog(uiTypes.DialogTypes.SoundingsLocations);
2297
+ const locationSelections = useSelector(getLocationSelections);
2298
+ const sourceSelections = useSelector(getSourceSelections);
2299
+ const selectedSourceIds = useMemo(() => Object.keys(sourceSelections).filter(key => sourceSelections[key].isChecked), [sourceSelections]);
841
2300
  const edrQueries = useMemo(() => soundingsCollections.map(collection => ({
842
2301
  queryKey: ['edr-locations', collection.id],
843
2302
  queryFn: async () => {
844
2303
  const locations = await getCollectionLocations(collection.id);
845
2304
  return {
846
2305
  ...locations,
847
- id: collection.id
2306
+ id: collection.id,
2307
+ name: collection.name
848
2308
  };
849
2309
  }
850
2310
  })), [soundingsCollections]);
@@ -855,275 +2315,14 @@ const LocationsMenuConnect = ({
855
2315
  return jsx(LocationsMenu, {
856
2316
  isOpen: isDialogOpen,
857
2317
  onClose: onCloseDialog,
858
- services: services
859
- });
860
- };
861
-
862
- var SoundingFeatures = [
863
- {
864
- type: "Feature",
865
- geometry: {
866
- type: "Point",
867
- coordinates: [
868
- 24.9727,
869
- 60.3294
870
- ]
871
- },
872
- properties: {
873
- time: "2025-02-05T00:00:00Z",
874
- temp: -4.74961,
875
- dewpoint: -5.646749999999998,
876
- pressure: 1000,
877
- windSpeed: 9.291457267156751,
878
- windDirection: -18.803465916723095
879
- }
880
- },
881
- {
882
- type: "Feature",
883
- geometry: {
884
- type: "Point",
885
- coordinates: [
886
- 24.9727,
887
- 60.3294
888
- ]
889
- },
890
- properties: {
891
- time: "2025-02-05T00:00:00Z",
892
- temp: -5.72214,
893
- dewpoint: -5.740760000000001,
894
- pressure: 925,
895
- windSpeed: 11.482972536081412,
896
- windDirection: 18.797638010822038
897
- }
898
- },
899
- {
900
- type: "Feature",
901
- geometry: {
902
- type: "Point",
903
- coordinates: [
904
- 24.9727,
905
- 60.3294
906
- ]
907
- },
908
- properties: {
909
- time: "2025-02-05T00:00:00Z",
910
- temp: -5.94461,
911
- dewpoint: -6.284489999999999,
912
- pressure: 850,
913
- windSpeed: 10.355155067433804,
914
- windDirection: 46.95919904362452
915
- }
916
- },
917
- {
918
- type: "Feature",
919
- geometry: {
920
- type: "Point",
921
- coordinates: [
922
- 24.9727,
923
- 60.3294
924
- ]
925
- },
926
- properties: {
927
- time: "2025-02-05T00:00:00Z",
928
- temp: -7.85121,
929
- dewpoint: -7.903229999999999,
930
- pressure: 700,
931
- windSpeed: 10.216260991987234,
932
- windDirection: 125.33681135255785
933
- }
934
- },
935
- {
936
- type: "Feature",
937
- geometry: {
938
- type: "Point",
939
- coordinates: [
940
- 24.9727,
941
- 60.3294
942
- ]
943
- },
944
- properties: {
945
- time: "2025-02-05T00:00:00Z",
946
- temp: -20.9667,
947
- dewpoint: -22.12218,
948
- pressure: 500,
949
- windSpeed: 14.222681508365433,
950
- windDirection: 120.16238041101973
951
- }
952
- },
953
- {
954
- type: "Feature",
955
- geometry: {
956
- type: "Point",
957
- coordinates: [
958
- 24.9727,
959
- 60.3294
960
- ]
961
- },
962
- properties: {
963
- time: "2025-02-05T00:00:00Z",
964
- temp: -48.8678,
965
- dewpoint: -60.802800000000005,
966
- pressure: 300,
967
- windSpeed: 23.436991331013456,
968
- windDirection: 128.0547684118523
969
- }
970
- },
971
- {
972
- type: "Feature",
973
- geometry: {
974
- type: "Point",
975
- coordinates: [
976
- 24.9727,
977
- 60.3294
978
- ]
979
- },
980
- properties: {
981
- time: "2025-02-05T00:00:00Z",
982
- temp: -58.3,
983
- dewpoint: -69.68567999999999,
984
- pressure: 250,
985
- windSpeed: 28.155356371568097,
986
- windDirection: 133.50317473958455
987
- }
988
- },
989
- {
990
- type: "Feature",
991
- geometry: {
992
- type: "Point",
993
- coordinates: [
994
- 24.9727,
995
- 60.3294
996
- ]
997
- },
998
- properties: {
999
- time: "2025-02-05T00:00:00Z",
1000
- temp: -68.6166,
1001
- dewpoint: -71.9641,
1002
- pressure: 200,
1003
- windSpeed: 24.833678713593763,
1004
- windDirection: 137.492458365928
1005
- }
1006
- },
1007
- {
1008
- type: "Feature",
1009
- geometry: {
1010
- type: "Point",
1011
- coordinates: [
1012
- 24.9727,
1013
- 60.3294
1014
- ]
1015
- },
1016
- properties: {
1017
- time: "2025-02-05T00:00:00Z",
1018
- temp: -69.4935,
1019
- dewpoint: -87.827772,
1020
- pressure: 50,
1021
- windSpeed: 35.1450619746217,
1022
- windDirection: 129.8915278264372
1023
- }
1024
- }
1025
- ];
1026
-
1027
- var MenuOptions;
1028
- (function (MenuOptions) {
1029
- MenuOptions["sources"] = "sources";
1030
- MenuOptions["locations"] = "locations";
1031
- })(MenuOptions || (MenuOptions = {}));
1032
- const SoundingsMenuBar = ({
1033
- handleClick,
1034
- isOpen
1035
- }) => {
1036
- const {
1037
- t
1038
- } = useSoundingsTranslation();
1039
- return jsxs(Box, {
1040
- display: "flex",
1041
- gap: 1,
1042
- children: [jsx(Button, {
1043
- variant: isOpen[MenuOptions.sources] ? 'primary' : 'tertiary',
1044
- disableRipple: true,
1045
- "aria-pressed": isOpen[MenuOptions.sources],
1046
- "data-testid": "soundingsSourcesButton",
1047
- onClick: () => handleClick(MenuOptions.sources),
1048
- startIcon: jsx(Sources, {}),
1049
- size: "small",
1050
- children: t('sources')
1051
- }), jsx(Button, {
1052
- variant: isOpen[MenuOptions.locations] ? 'primary' : 'tertiary',
1053
- disableRipple: true,
1054
- "data-testid": "soundingsLocationsButton",
1055
- "aria-pressed": isOpen[MenuOptions.locations],
1056
- onClick: () => handleClick(MenuOptions.locations),
1057
- size: "small",
1058
- children: t('locations')
1059
- })]
1060
- });
1061
- };
1062
-
1063
- const MenuBarConnect = ({
1064
- viewId,
1065
- source
1066
- }) => {
1067
- const dispatch = useDispatch();
1068
- const currentActiveMapId = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsSources));
1069
- const isSourcesOpenInStore = useSelector(store => uiSelectors.getisDialogOpen(store, uiTypes.DialogTypes.SoundingsSources));
1070
- const isLocationsOpenInStore = useSelector(store => uiSelectors.getisDialogOpen(store, uiTypes.DialogTypes.soundingsLocations));
1071
- const dispatchActiveMapId = React.useCallback((dialogType, isOpen) => void dispatch(uiActions.setActiveMapIdForDialog({
1072
- type: dialogType,
1073
- mapId: viewId,
1074
- setOpen: currentActiveMapId !== viewId ? true : !isOpen,
1075
- source
1076
- })), [dispatch, currentActiveMapId, viewId, source]);
1077
- const handleClick = React.useCallback(menuType => {
1078
- switch (menuType) {
1079
- case MenuOptions.sources:
1080
- dispatchActiveMapId(uiTypes.DialogTypes.SoundingsSources, isSourcesOpenInStore);
1081
- break;
1082
- case MenuOptions.locations:
1083
- dispatchActiveMapId(uiTypes.DialogTypes.soundingsLocations, isLocationsOpenInStore);
1084
- break;
1085
- }
1086
- }, [dispatchActiveMapId, isSourcesOpenInStore, isLocationsOpenInStore]);
1087
- const isOpen = {
1088
- [MenuOptions.sources]: currentActiveMapId === viewId && isSourcesOpenInStore,
1089
- [MenuOptions.locations]: currentActiveMapId === viewId && isLocationsOpenInStore
1090
- };
1091
- return jsx(SoundingsMenuBar, {
1092
- isOpen: isOpen,
1093
- handleClick: handleClick
1094
- });
1095
- };
1096
-
1097
- const SoundingsConnect = ({
1098
- viewId
1099
- }) => {
1100
- return jsxs(Box, {
1101
- sx: {
1102
- padding: 2
1103
- },
1104
- children: [jsx(Box, {
1105
- paddingLeft: 5,
1106
- children: jsx(MenuBarConnect, {
1107
- viewId: viewId
1108
- })
1109
- }), jsx(Soundings
1110
- // TODO: use real data
1111
- , {
1112
- // TODO: use real data
1113
- data: {
1114
- features: SoundingFeatures
1115
- },
1116
- svgDimensions: {
1117
- width: 600,
1118
- height: 600,
1119
- margin: {
1120
- bottom: 0,
1121
- left: 0,
1122
- right: 0,
1123
- top: 0
1124
- }
1125
- }
1126
- })]
2318
+ services: services.filter(service => selectedSourceIds.includes(service.id)),
2319
+ selectedLocations: locationSelections,
2320
+ updateSelectedLocation: (sourceId, locationId) => void dispatch(soundingsActions.updateSelectedLocation({
2321
+ sourceId,
2322
+ locationId
2323
+ })),
2324
+ onMouseDown: setDialogOrder,
2325
+ order: dialogOrder
1127
2326
  });
1128
2327
  };
1129
2328
 
@@ -1137,4 +2336,32 @@ const soundingsLookUp = payload => {
1137
2336
  return null;
1138
2337
  };
1139
2338
 
1140
- export { LocationsMenuConnect, Soundings, SoundingsSourceDialogConnect, soundingsComponentType, soundingsLookUp };
2339
+ /* *
2340
+ * Licensed under the Apache License, Version 2.0 (the "License");
2341
+ * you may not use this file except in compliance with the License.
2342
+ * You may obtain a copy of the License at
2343
+ *
2344
+ * http://www.apache.org/licenses/LICENSE-2.0
2345
+ *
2346
+ * Unless required by applicable law or agreed to in writing, software
2347
+ * distributed under the License is distributed on an "AS IS" BASIS,
2348
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2349
+ * See the License for the specific language governing permissions and
2350
+ * limitations under the License.
2351
+ *
2352
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
2353
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
2354
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
2355
+ * */
2356
+ const soundingsReducersMap = {
2357
+ soundings: soundingsReducer,
2358
+ ...storeReducerMap
2359
+ };
2360
+ const soundingsRootReducer = combineReducers(soundingsReducersMap);
2361
+ const createMockStore = mockState => configureStore({
2362
+ reducer: soundingsRootReducer,
2363
+ preloadedState: mockState,
2364
+ middleware: getDefaultMiddleware => getDefaultMiddleware().concat(storeMiddlewares)
2365
+ });
2366
+
2367
+ export { LocationsMenuConnect, SOUNDINGS_NAMESPACE, SoundingsConnect, SoundingsSourceDialogConnect, createMockStore, initSoundingsI18n, soundingsComponentType, soundingsLookUp, soundingsReducersMap, soundingsTranslations, translateKeyOutsideComponents, useSoundingsTranslation };