@opengeoweb/soundings 12.4.2 → 12.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +49 -1
  2. package/index.esm.js +715 -225
  3. package/package.json +9 -7
  4. package/src/lib/api/edr-api/api.d.ts +6 -5
  5. package/src/lib/api/edr-api/hooks.d.ts +20 -1
  6. package/src/lib/api/edr-api/types.d.ts +3 -64
  7. package/src/lib/api/edr-api/utils.d.ts +10 -2
  8. package/src/lib/api/edr-api/utils.spec.d.ts +1 -0
  9. package/src/lib/components/MenuBar/LocationsMenu.d.ts +4 -1
  10. package/src/lib/components/MenuBar/LocationsMenuConnect.spec.d.ts +1 -0
  11. package/src/lib/components/Soundings/Soundings.d.ts +2 -1
  12. package/src/lib/components/Soundings/Soundings.stories.d.ts +2 -0
  13. package/src/lib/components/Soundings/constants.d.ts +16 -0
  14. package/src/lib/components/Soundings/drawUtils.d.ts +14 -7
  15. package/src/lib/components/Soundings/types.d.ts +1 -0
  16. package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSlider.stories.d.ts +1 -0
  17. package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSliderConnect.d.ts +1 -5
  18. package/src/lib/components/SoundingsTooltip/utils.d.ts +3 -2
  19. package/src/lib/components/StaticLines/bijvoetUtils.d.ts +6 -0
  20. package/src/lib/components/StaticLines/drawDryAdiabats.d.ts +10 -2
  21. package/src/lib/components/StaticLines/drawIsobars.d.ts +6 -3
  22. package/src/lib/components/StaticLines/drawIsohumes.d.ts +11 -2
  23. package/src/lib/components/StaticLines/drawIsotherms.d.ts +11 -2
  24. package/src/lib/components/StaticLines/drawMoistAdiabats.d.ts +11 -2
  25. package/src/lib/components/StaticLines/utils.d.ts +6 -3
  26. package/src/lib/components/WindBarbs/utils.d.ts +3 -4
  27. package/src/lib/store/selectors.d.ts +41 -0
  28. package/src/lib/store/types.d.ts +4 -0
  29. package/src/lib/api/edr-api/crss-types.d.ts +0 -26
package/index.esm.js CHANGED
@@ -4,8 +4,10 @@ import { Card, Box, Stack, LinearProgress, List, ListItem, ListItemIcon, Checkbo
4
4
  import { safelyInitI18nForTestsAndStories, sharedTranslations, SHARED_NAMESPACE, dateUtils, CustomSlider, CustomToggleButton, AlertBanner, ToolContainerDraggable, StyledSelect, SearchField, CustomAccordion } from '@opengeoweb/shared';
5
5
  import React, { useRef, useEffect, useMemo, useState, Fragment as Fragment$1 } from 'react';
6
6
  import * as d3 from 'd3';
7
- import { useQueries, useQuery } from '@tanstack/react-query';
7
+ import { useQueries } from '@tanstack/react-query';
8
8
  import { getHeaders, handleResponse } from '@opengeoweb/api';
9
+ import { ParseISOTimeRangeDuration, parseISO8601IntervalToDateInterval } from '@opengeoweb/webmap';
10
+ import { range } from 'lodash';
9
11
  import { timesliderTranslations, TIMESLIDER_NAMESPACE, BackwardForwardStepButton } from '@opengeoweb/timeslider';
10
12
  import i18n from 'i18next';
11
13
  export { default as i18n } from 'i18next';
@@ -54,6 +56,119 @@ var soundingsTranslations = {
54
56
  fi: fi
55
57
  };
56
58
 
59
+ /* *
60
+ * Licensed under the Apache License, Version 2.0 (the "License");
61
+ * you may not use this file except in compliance with the License.
62
+ * You may obtain a copy of the License at
63
+ *
64
+ * http://www.apache.org/licenses/LICENSE-2.0
65
+ *
66
+ * Unless required by applicable law or agreed to in writing, software
67
+ * distributed under the License is distributed on an "AS IS" BASIS,
68
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
69
+ * See the License for the specific language governing permissions and
70
+ * limitations under the License.
71
+ *
72
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
73
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
74
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
75
+ * */
76
+ // Define pressure and temperature values for static lines
77
+ const maxPressure = 1050;
78
+ const minPressure = 150;
79
+ const staticPressureArray = [maxPressure];
80
+ const dp = -5;
81
+ const minTemperature = -40;
82
+ const maxTemperature = 40.6;
83
+ let pressure = maxPressure;
84
+ while (pressure > minPressure) {
85
+ pressure += dp;
86
+ staticPressureArray.push(pressure);
87
+ }
88
+ // Bijvoet constants
89
+ const T_KAPPA = 0.2857;
90
+ const T_KTHETA = 332.0;
91
+ const KELVIN = 273.156;
92
+ const WV_CA = 17.56881;
93
+ const WV_CB = 241.8945;
94
+ const WV_CE = 610.7;
95
+ const EPSILON = 0.622;
96
+ const R_AIR = 287.04;
97
+ const HEATCP = 1005;
98
+ const REFERENCE_PRESSURE = 1000 * 100; // 1000hPa = 100000Pa
99
+
100
+ /* *
101
+ * Licensed under the Apache License, Version 2.0 (the "License");
102
+ * you may not use this file except in compliance with the License.
103
+ * You may obtain a copy of the License at
104
+ *
105
+ * http://www.apache.org/licenses/LICENSE-2.0
106
+ *
107
+ * Unless required by applicable law or agreed to in writing, software
108
+ * distributed under the License is distributed on an "AS IS" BASIS,
109
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
110
+ * See the License for the specific language governing permissions and
111
+ * limitations under the License.
112
+ *
113
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
114
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
115
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
116
+ * */
117
+ const tempToKelvin = value => value + KELVIN;
118
+ // Calculate pressure in Pa instead of hPa
119
+ const pressToPascal = value => value * 100;
120
+ const bijvoetInterpolationTheta = (minRange, maxRange) => {
121
+ return t => {
122
+ const min = minTemperature + KELVIN;
123
+ const max = maxTemperature + KELVIN;
124
+ const xCoord = (Math.sign(t - T_KTHETA) * Math.abs(t - T_KTHETA) ** T_KAPPA - Math.sign(min - T_KTHETA) * Math.abs(min - T_KTHETA) ** T_KAPPA) / (Math.sign(max - T_KTHETA) * Math.abs(max - T_KTHETA) ** T_KAPPA - Math.sign(min - T_KTHETA) * Math.abs(min - T_KTHETA) ** T_KAPPA);
125
+ return minRange * (1 - xCoord) + maxRange * xCoord;
126
+ };
127
+ };
128
+ const bijvoetInterpolationPres = (minRange, maxRange) => {
129
+ return t => {
130
+ const yCoord = (pressToPascal(t) ** T_KAPPA - pressToPascal(minPressure) ** T_KAPPA) / (pressToPascal(maxPressure) ** T_KAPPA - pressToPascal(minPressure) ** T_KAPPA);
131
+ return minRange * (1 - yCoord) + maxRange * yCoord;
132
+ };
133
+ };
134
+ // Returns pressure in hPa
135
+ const pressureFromPosition = y => {
136
+ return (y * (pressToPascal(maxPressure) ** T_KAPPA - pressToPascal(minPressure) ** T_KAPPA) + pressToPascal(minPressure) ** T_KAPPA) ** (1 / T_KAPPA) / 100;
137
+ };
138
+ const calculateESat = temperature => {
139
+ const tempKelvin = temperature - KELVIN;
140
+ return WV_CE * Math.exp(WV_CA * tempKelvin / (tempKelvin + WV_CB));
141
+ };
142
+ const latHeat = temperature => {
143
+ return R_AIR / EPSILON * WV_CA * WV_CB * (temperature / (temperature - KELVIN + WV_CB)) ** 2;
144
+ };
145
+ const dTdp = (pressure, temperature) => {
146
+ const eSat = calculateESat(temperature);
147
+ const Rs = EPSILON * eSat / (pressure - eSat);
148
+ const Lv = latHeat(temperature);
149
+ const dTdp = 2.0 * temperature / (7.0 * pressure);
150
+ const dTdp1 = dTdp * (1.0 + Rs * Lv / (R_AIR * temperature));
151
+ const dTdp2 = dTdp1 / (1.0 + Rs / (7.0 * EPSILON) + (Lv / temperature) ** 2 * Rs * EPSILON / (R_AIR * HEATCP));
152
+ return dTdp2;
153
+ };
154
+ const calculateThetaFromPressTemp = (pressure, temperature, basePressure) => {
155
+ const base = basePressure || REFERENCE_PRESSURE;
156
+ const dpPascal = pressure > base ? pressToPascal(-10) : pressToPascal(10); // hPa steps to Pa steps
157
+ const numberOfSteps = Math.round((base - pressure) / dpPascal) < 1 ? 1 : Math.round((base - pressure) / dpPascal);
158
+ const pstep = (base - pressure) / numberOfSteps;
159
+ let tempN = temperature;
160
+ let pressureN = pressure;
161
+ for (let n = 0; n < numberOfSteps; n += 1) {
162
+ const k1 = pstep * dTdp(pressureN, tempN);
163
+ const k2 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k1);
164
+ const k3 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k2);
165
+ const k4 = pstep * dTdp(pressureN + pstep, tempN + k3);
166
+ tempN += k1 / 6.0 + k2 / 3.0 + k3 / 3.0 + k4 / 6.0;
167
+ pressureN += pstep;
168
+ }
169
+ return tempN;
170
+ };
171
+
57
172
  /* *
58
173
  * Licensed under the Apache License, Version 2.0 (the "License");
59
174
  * you may not use this file except in compliance with the License.
@@ -73,22 +188,25 @@ var soundingsTranslations = {
73
188
  * */
74
189
  const isobarValues = [200, 300, 400, 500, 700, 850, 925, 1000];
75
190
  const drawIsobars = ({
191
+ diagramType,
76
192
  minTemperature,
77
193
  maxTemperature,
78
194
  xScale,
79
195
  yScale,
80
196
  svg
81
197
  }) => {
82
- // Calculate X and Y coordinates for each pressure level
198
+ // For Bijvoet, the x-axis is NOT temperature but theta_es
199
+ const xmin = diagramType === 'BIJVOET' ? tempToKelvin(minTemperature) : minTemperature;
200
+ const xmax = diagramType === 'BIJVOET' ? tempToKelvin(maxTemperature) : maxTemperature;
83
201
  for (const pressureVal of isobarValues) {
84
202
  const Px = [];
85
- const tempBounds = [minTemperature, maxTemperature];
203
+ const tempBounds = [xmin, xmax];
86
204
  for (const bound of tempBounds) {
87
205
  Px.push([xScale(bound), yScale(pressureVal)]);
88
206
  }
89
207
  const lineGenerator = d3.line();
90
208
  const isoPathString = lineGenerator(Px);
91
- const strID = `${pressureVal}textId`;
209
+ const strID = `${pressureVal}-isobar`;
92
210
  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
211
  }
94
212
  };
@@ -116,37 +234,56 @@ const initializeSvg = ({
116
234
  svgRef,
117
235
  width,
118
236
  height,
119
- margin
237
+ margin,
238
+ diagramType
120
239
  }) => {
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})`);
122
- svg.append('clipPath').attr('id', 'clip-skewTd3').append('rect').attr('width', width).attr('height', height).attr('x', 0).attr('y', 0);
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)');
240
+ const diagram = diagramType === 'BIJVOET' ? 'bijvoet' : 'skewT';
241
+ const svg = d3.select(svgRef.current).attr('width', width + (margin.left + margin.right)).attr('height', height + (margin.top + margin.bottom)).attr('id', diagram).attr('data-testid', diagram).append('g').attr('transform', `translate(${margin.left + margin.right},${margin.top})`);
242
+ svg.append('clipPath').attr('id', `clip-${diagram}d3`).append('rect').attr('width', width).attr('height', height).attr('x', 0).attr('y', 0);
243
+ svg.append('rect').attr('height', height).attr('width', width).attr('fill', '#ffffff00').attr('opacity', 1).attr('id', `${diagram}d3`).attr('clip-path', `url(#clip-${diagram}d3)`);
124
244
  return svg;
125
245
  };
246
+ const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
247
+ if (diagramType === 'BIJVOET') {
248
+ // Note, for Bijvoet, the x-axis is NOT temperature but theta_es
249
+ // Both axis aren't linear nor logarithmic, hence we need an interpolation function to determine (x,y) based on (theta_es,p)
250
+ // These scales work the same as for SkewT -> you pass in a pressure (or theta value) and you receive the location in the svg
251
+ const xScale = d3.scaleLinear().domain([0, 1]).range([0, width]).interpolate(bijvoetInterpolationTheta);
252
+ const yScale = d3.scaleLinear().domain([1, 0]).range([height, 0]).interpolate(bijvoetInterpolationPres);
253
+ return [xScale, yScale];
254
+ }
255
+ const xScale = d3.scaleLinear().domain([minTemperature, maxTemperature]).range([0, width]);
256
+ const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
257
+ return [xScale, yScale];
258
+ };
126
259
  const drawAxes = ({
127
260
  svg,
128
261
  xScale,
129
262
  yScale,
130
- height
263
+ height,
264
+ diagramType
131
265
  }) => {
132
- // Create x-axis with intervals
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
- });
266
+ // For Bijvoet, we don't show an x-axis as we're interested in the isotherms only, not theta
267
+ if (diagramType !== 'BIJVOET') {
268
+ // Create x-axis with intervals
269
+ const xTicks = xScale.ticks(10);
270
+ const xMiddleValue = xTicks[Math.floor(xTicks.length / 2)];
271
+ svg.append('g').attr('type', 'x-axis').attr('transform', `translate(0,${height})`).call(d3.axisBottom(xScale).tickValues(xTicks).tickSizeInner(0).tickSizeOuter(0));
272
+ // Reformat in the case of the middle tick label
273
+ // to add the unit part
274
+ svg.selectAll('g[type="x-axis"] .tick').filter(function () {
275
+ return parseInt(d3.select(this).select('text').text(), 10) === xMiddleValue;
276
+ }).each(function () {
277
+ const unitPart = '°C';
278
+ d3.select(this).select('text')
279
+ // tspan necessary to use a different font-size
280
+ .append('tspan').text(unitPart).attr('font-size', '11px').attr('dy', '0.04em');
281
+ });
282
+ }
146
283
  // Create y-axis with intervals
147
284
  // Make sure all zeroes are printed (1000 vs 1k)
148
285
  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');
286
+ svg.append('g').attr('type', 'y-axis').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');
150
287
  };
151
288
  const addLegends = ({
152
289
  svg,
@@ -157,6 +294,15 @@ const addLegends = ({
157
294
  // Add a legend for the dewpoint line
158
295
  svg.append('text').attr('x', width - 137).attr('y', 40).attr('fill', SOUNDING_COLORS[0]).text('Dewpoint (°C)');
159
296
  };
297
+ const mapToPlot = (diagramType, filteredPressureValues, filteredTemperatureValues, xScale, yScale, height) => {
298
+ if (diagramType === 'BIJVOET') {
299
+ const thetaValues = filteredTemperatureValues.map((tempValue, index) => calculateThetaFromPressTemp(pressToPascal(filteredPressureValues[index]), tempToKelvin(tempValue)));
300
+ return d3.zip(filteredPressureValues, thetaValues).map(([pressure, theta]) => [xScale(theta), yScale(pressure)]);
301
+ }
302
+ const Px = d3.zip(filteredPressureValues, filteredTemperatureValues).map(([pressure, temperature]) => [xScale(temperature), yScale(pressure)]);
303
+ // Skew/shear transformation for skew-t
304
+ return Px.map(([x, y]) => [x + height - y, y]);
305
+ };
160
306
  const plotSounding = ({
161
307
  temperatureValues,
162
308
  pressureValues,
@@ -165,16 +311,16 @@ const plotSounding = ({
165
311
  xScale,
166
312
  yScale,
167
313
  height,
168
- svg
314
+ svg,
315
+ diagramType
169
316
  }) => {
317
+ const diagram = diagramType === 'BIJVOET' ? 'bijvoet' : 'skewT';
170
318
  const [filteredPressureValues, filteredTemperatureValues] = filterDomainRangeForParameter(pressureValues, temperatureValues);
171
319
  // Map temp and pressure to pixels
172
- const Px = d3.zip(filteredPressureValues, filteredTemperatureValues).map(([pressure, temperature]) => [xScale(temperature), yScale(pressure)]);
173
- // Skew/shear transformation for skew-t
174
- const skewedPx = Px.map(([x, y]) => [x + height - y, y]);
320
+ const Px = mapToPlot(diagramType, filteredPressureValues, filteredTemperatureValues, xScale, yScale, height);
175
321
  const lineGenerator = d3.line();
176
- const pathString = lineGenerator(skewedPx);
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)');
322
+ const pathString = lineGenerator(Px);
323
+ svg.append('path').attr('d', pathString).attr('id', 'dataline').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-${diagram}d3)`);
178
324
  };
179
325
  // Use this function to filter the pressure and parameter array so no NaN or null values are included
180
326
  const filterDomainRangeForParameter = (pressureDomain, parameterDomain) => {
@@ -248,6 +394,13 @@ const getParamHtml = (paramValue, colorIndex, unit, isLastElement, noDistance =
248
394
  }
249
395
  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
396
  };
397
+ const extractPressureFromY = (diagramType, y, yScale) => {
398
+ if (diagramType === 'BIJVOET') {
399
+ const frac = yScale.invert(y);
400
+ return pressureFromPosition(frac);
401
+ }
402
+ return yScale.invert(y);
403
+ };
251
404
  const drawSoundingsTooltip = ({
252
405
  event,
253
406
  yScale,
@@ -258,14 +411,15 @@ const drawSoundingsTooltip = ({
258
411
  windRefd3,
259
412
  margin,
260
413
  width,
261
- tooltipData
414
+ tooltipData,
415
+ diagramType
262
416
  }) => {
263
417
  if (tooltipData.length === 0) {
264
418
  return;
265
419
  }
266
420
  const [, y] = d3.pointer(event);
267
421
  const pressureY = event.clientY - svgRectangle.top - 10;
268
- const pressure = yScale.invert(y);
422
+ const pressure = extractPressureFromY(diagramType, y, yScale);
269
423
  const interpolatedData = tooltipData.map(dataSet => {
270
424
  return {
271
425
  hoveredPressure: dataSet.interpolatePressure(pressure),
@@ -658,12 +812,10 @@ const extendXAxis = (path, horizontalBarbMargin) => {
658
812
  };
659
813
  const drawWindBarbs = ({
660
814
  svgRef,
815
+ yScale,
661
816
  data,
662
817
  width,
663
- height,
664
818
  margin,
665
- minPressure,
666
- maxPressure,
667
819
  barbColor = 'black'
668
820
  }) => {
669
821
  const barbsSize = 25;
@@ -690,7 +842,6 @@ const drawWindBarbs = ({
690
842
  d3.select(this).attr('d', newD);
691
843
  });
692
844
  svg.attr('width', currentWidth + margin.left + horizontalBarbMargin);
693
- const pressureScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]).clamp(true);
694
845
  const barbGroup = svg.append('g').attr('class', 'wind-group').attr('transform',
695
846
  // This translation should be revisited when implementing
696
847
  // the plotting of several datasets at once
@@ -718,7 +869,7 @@ const drawWindBarbs = ({
718
869
  const cx = bbox.x + bbox.width / 2;
719
870
  const cy = bbox.y + bbox.height / 2;
720
871
  const rotation = 180 + data.windDirection;
721
- const y = pressureScale(data.pressure);
872
+ const y = yScale(data.pressure);
722
873
  // Place items in the correct vertical position
723
874
  // Correcting for barbSize
724
875
  group.attr('transform', `translate(0, ${y - barbsSize * scalingFactor / 8})`);
@@ -777,7 +928,7 @@ const getWindSymbol = value => {
777
928
  const CP$1 = 1.03e3;
778
929
  const Po = 1000.0;
779
930
  const dryAdiabaticLapserate = (theta, pressure, temperature, dp) => {
780
- const Tt = (theta + 273.15) * (Po / pressure) ** (-287 / CP$1) - 273.15;
931
+ const Tt = (theta + KELVIN) * (Po / pressure) ** (-287 / CP$1) - KELVIN;
781
932
  const dt = Tt - temperature;
782
933
  return [dp, dt];
783
934
  };
@@ -808,6 +959,31 @@ const drawDryAdiabats = ({
808
959
  svg.append('path').attr('d', daPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'green').style('opacity', 0.3).attr('clip-path', 'url(#clip-skewTd3)');
809
960
  }
810
961
  };
962
+ const tempPresDryAiabat = (pressure, pres0, temp0) => temp0 * (pressure / pres0) ** (2.0 / 7.0);
963
+ const drawDryAdiabatsBijvoet = ({
964
+ staticPressureArray,
965
+ xScale,
966
+ yScale,
967
+ svg
968
+ }) => {
969
+ for (let Tbase = -20; Tbase < 40; Tbase += 5) {
970
+ const Px = [];
971
+ const tempKelvin = tempToKelvin(Tbase);
972
+ const tempDry = calculateThetaFromPressTemp(REFERENCE_PRESSURE, tempKelvin, 1000);
973
+ for (const pressure of staticPressureArray) {
974
+ const pres = pressToPascal(pressure); // Pressure in Pa
975
+ const tempPresDry = tempPresDryAiabat(pres, 1000, tempDry);
976
+ const theta = calculateThetaFromPressTemp(pres, tempPresDry);
977
+ const Ppx = yScale(pressure);
978
+ const Tpx = xScale(theta);
979
+ Px.push([Tpx, Ppx]);
980
+ }
981
+ const lineGenerator = d3.line();
982
+ const daPathString = lineGenerator(Px);
983
+ const strID = `${Tbase}-dryadiabat`;
984
+ svg.append('path').attr('id', strID).attr('d', daPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'green').style('opacity', 0.3).attr('clip-path', 'url(#clip-bijvoetd3)');
985
+ }
986
+ };
811
987
 
812
988
  /* *
813
989
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -853,6 +1029,35 @@ const drawIsohumes = ({
853
1029
  svg.append('text').text(isohumeVal).attr('text-anchor', 'middle').attr('x', `${Px[0][0] + 20}px`).attr('y', `${height - 10}px`).attr('fill', '#5267A9').attr('font-size', '10px');
854
1030
  }
855
1031
  };
1032
+ const isohumeValuesBijvoet = [1, 2, 4, 7, 11, 16, 22, 29];
1033
+ const mixr2Tdew = (mixr, pressure) => {
1034
+ const mixingRation = mixr < 1e-6 ? 1e-6 : mixr;
1035
+ const eVap = pressure * mixingRation / (EPSILON + mixingRation);
1036
+ return WV_CB / (WV_CA / Math.log(eVap / WV_CE) - 1.0) + KELVIN;
1037
+ };
1038
+ const drawIsohumesBijvoet = ({
1039
+ staticPressureArray,
1040
+ height,
1041
+ xScale,
1042
+ yScale,
1043
+ svg
1044
+ }) => {
1045
+ for (const isohumeVal of isohumeValuesBijvoet) {
1046
+ const Px = [];
1047
+ const mixr = isohumeVal * 1e-3;
1048
+ for (const pressureVal of staticPressureArray) {
1049
+ const pres = pressToPascal(pressureVal); // Pressure in Pa
1050
+ const Tdew = mixr2Tdew(mixr, pres);
1051
+ const theta = calculateThetaFromPressTemp(pres, Tdew, REFERENCE_PRESSURE);
1052
+ Px.push([xScale(theta), yScale(pressureVal)]);
1053
+ }
1054
+ const lineGenerator = d3.line();
1055
+ const pathStringQ = lineGenerator(Px);
1056
+ const strID = `${isohumeVal}isohume`;
1057
+ svg.append('path').attr('id', strID).attr('d', pathStringQ).style('fill', 'none').style('stroke-width', 1).style('stroke-dasharray', '15, 8').style('stroke', '#5267A9').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-bijvoetd3)');
1058
+ svg.append('text').text(isohumeVal).attr('text-anchor', 'middle').attr('x', `${Px[0][0] + 20}px`).attr('y', `${height - 10}px`).attr('fill', '#5267A9').attr('font-size', '10px');
1059
+ }
1060
+ };
856
1061
 
857
1062
  /* *
858
1063
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -892,6 +1097,32 @@ const drawIsotherms = ({
892
1097
  svg.append('path').attr('d', pathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'orange').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-skewTd3)');
893
1098
  }
894
1099
  };
1100
+ const drawIsothermsBijvoet = ({
1101
+ staticPressureArray,
1102
+ xScale,
1103
+ yScale,
1104
+ svg,
1105
+ height
1106
+ }) => {
1107
+ // lines of constant temperature (10 degree interval from -80...+40C)
1108
+ for (let temp = -150; temp <= 40; temp += 2) {
1109
+ const points = [];
1110
+ // start locatie van elke isotherm
1111
+ const Tiso = tempToKelvin(temp);
1112
+ for (const pressure of staticPressureArray) {
1113
+ const theta = calculateThetaFromPressTemp(pressToPascal(pressure), Tiso, REFERENCE_PRESSURE);
1114
+ points.push([xScale(theta), yScale(pressure)]);
1115
+ }
1116
+ const lineGenerator = d3.line();
1117
+ const pathString = lineGenerator(points);
1118
+ const strID = `${temp}-isotherm-bijvoet`;
1119
+ svg.append('path').attr('id', strID).attr('d', pathString).style('fill', 'none').style('stroke-width', temp % 10 === 0 ? 2 : 1).style('stroke', temp === 0 ? '#D27D2D' : 'orange').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-bijvoetd3)');
1120
+ // only show value every 10 degrees and if isotherm reaches bottom of plot
1121
+ if (temp % 10 === 0 && points[0] && points[0][1] === height) {
1122
+ svg.append('text').text(temp).attr('text-anchor', 'middle').attr('x', `${points[0][0]}px`).attr('y', `${height + 12}px`).attr('fill', temp === 0 ? '#D27D2D' : 'orange').attr('font-size', '10px');
1123
+ }
1124
+ }
1125
+ };
895
1126
 
896
1127
  /* *
897
1128
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -911,7 +1142,6 @@ const drawIsotherms = ({
911
1142
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
912
1143
  * */
913
1144
  const CP = 1.03e3;
914
- const KELVIN = 273.15;
915
1145
  const L_CONST = 2.5e6;
916
1146
  const RD = 287.0;
917
1147
  const RV = 461.0;
@@ -987,6 +1217,26 @@ const drawMoistAdiabats = ({
987
1217
  svg.append('path').attr('d', maPathStringBottom).style('fill', 'none').style('stroke-width', 1).style('stroke', 'purple').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-skewTd3)');
988
1218
  }
989
1219
  };
1220
+ const drawMoistAdiabatsBijvoet = ({
1221
+ minPressure,
1222
+ maxPressure,
1223
+ xScale,
1224
+ yScale,
1225
+ svg
1226
+ }) => {
1227
+ for (let Tbase = -20; Tbase < 40; Tbase += 5) {
1228
+ const theta = Tbase + KELVIN;
1229
+ const Px = [];
1230
+ const pressureBounds = [minPressure, maxPressure];
1231
+ for (const bound of pressureBounds) {
1232
+ Px.push([xScale(theta), yScale(bound)]);
1233
+ }
1234
+ const lineGenerator = d3.line();
1235
+ const isoPathString = lineGenerator(Px);
1236
+ const strID = `${Tbase}-moistadiabat`;
1237
+ svg.append('path').attr('id', strID).attr('d', isoPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'purple').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-bijvoetd3)');
1238
+ }
1239
+ };
990
1240
 
991
1241
  /* *
992
1242
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -1006,6 +1256,7 @@ const drawMoistAdiabats = ({
1006
1256
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1007
1257
  * */
1008
1258
  const drawStaticLines = ({
1259
+ diagramType,
1009
1260
  dp,
1010
1261
  staticPressureArray,
1011
1262
  minTemperature,
@@ -1019,47 +1270,78 @@ const drawStaticLines = ({
1019
1270
  svg
1020
1271
  }) => {
1021
1272
  drawIsobars({
1273
+ diagramType,
1022
1274
  minTemperature,
1023
1275
  maxTemperature,
1024
1276
  xScale,
1025
1277
  yScale,
1026
1278
  svg
1027
1279
  });
1028
- drawIsotherms({
1029
- staticPressureArray,
1030
- height,
1031
- xScale,
1032
- yScale,
1033
- svg
1034
- });
1035
- drawIsohumes({
1036
- staticPressureArray,
1037
- height,
1038
- xScale,
1039
- yScale,
1040
- svg
1041
- });
1042
- drawDryAdiabats({
1043
- staticPressureArray,
1044
- height,
1045
- xScale,
1046
- yScale,
1047
- svg,
1048
- dp
1049
- });
1050
- drawMoistAdiabats({
1051
- staticPressureArray,
1052
- minTemperature,
1053
- maxTemperature,
1054
- minPressure,
1055
- maxPressure,
1056
- width,
1057
- height,
1058
- xScale,
1059
- yScale,
1060
- svg,
1061
- dp
1062
- });
1280
+ if (diagramType === 'BIJVOET') {
1281
+ drawMoistAdiabatsBijvoet({
1282
+ minPressure,
1283
+ maxPressure,
1284
+ xScale,
1285
+ yScale,
1286
+ svg
1287
+ });
1288
+ drawIsothermsBijvoet({
1289
+ staticPressureArray,
1290
+ xScale,
1291
+ yScale,
1292
+ svg,
1293
+ height
1294
+ });
1295
+ drawDryAdiabatsBijvoet({
1296
+ staticPressureArray,
1297
+ xScale,
1298
+ yScale,
1299
+ svg
1300
+ });
1301
+ drawIsohumesBijvoet({
1302
+ staticPressureArray,
1303
+ height,
1304
+ xScale,
1305
+ yScale,
1306
+ svg
1307
+ });
1308
+ } else {
1309
+ drawIsotherms({
1310
+ staticPressureArray,
1311
+ height,
1312
+ xScale,
1313
+ yScale,
1314
+ svg
1315
+ });
1316
+ drawMoistAdiabats({
1317
+ staticPressureArray,
1318
+ minTemperature,
1319
+ maxTemperature,
1320
+ minPressure,
1321
+ maxPressure,
1322
+ width,
1323
+ height,
1324
+ xScale,
1325
+ yScale,
1326
+ svg,
1327
+ dp
1328
+ });
1329
+ drawDryAdiabats({
1330
+ staticPressureArray,
1331
+ height,
1332
+ xScale,
1333
+ yScale,
1334
+ svg,
1335
+ dp
1336
+ });
1337
+ drawIsohumes({
1338
+ staticPressureArray,
1339
+ height,
1340
+ xScale,
1341
+ yScale,
1342
+ svg
1343
+ });
1344
+ }
1063
1345
  };
1064
1346
 
1065
1347
  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)';
@@ -1090,22 +1372,11 @@ const soundingsStyle = {
1090
1372
  fontWeight: 'bold'
1091
1373
  }
1092
1374
  };
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
1375
  const Soundings = ({
1106
1376
  data,
1107
1377
  svgDimensions,
1108
- showTooltip = true
1378
+ showTooltip = true,
1379
+ diagramType = 'SKEWT'
1109
1380
  }) => {
1110
1381
  const svgRef = useRef(null);
1111
1382
  const canvasRef = useRef(null);
@@ -1139,21 +1410,23 @@ const Soundings = ({
1139
1410
  svgRef,
1140
1411
  width,
1141
1412
  height,
1142
- margin
1413
+ margin,
1414
+ diagramType
1143
1415
  });
1144
1416
  const svgRectangle = svgRef.current.getBoundingClientRect();
1145
1417
  // Set scales for temperature and pressure
1146
- const xScale = d3.scaleLinear().domain([minTemperature, maxTemperature]).range([0, width]);
1147
- const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
1418
+ const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature);
1148
1419
  // Add axis'
1149
1420
  drawAxes({
1150
1421
  svg,
1151
1422
  xScale,
1152
1423
  yScale,
1153
- height
1424
+ height,
1425
+ diagramType
1154
1426
  });
1155
1427
  // Draw static lines
1156
1428
  drawStaticLines({
1429
+ diagramType,
1157
1430
  dp,
1158
1431
  staticPressureArray,
1159
1432
  minTemperature,
@@ -1184,7 +1457,8 @@ const Soundings = ({
1184
1457
  xScale,
1185
1458
  yScale,
1186
1459
  height,
1187
- svg
1460
+ svg,
1461
+ diagramType
1188
1462
  });
1189
1463
  const dewpointValues = features.map(feature => feature.properties.dewpoint);
1190
1464
  // Dewpoint
@@ -1196,7 +1470,8 @@ const Soundings = ({
1196
1470
  xScale,
1197
1471
  yScale,
1198
1472
  height,
1199
- svg
1473
+ svg,
1474
+ diagramType
1200
1475
  });
1201
1476
  const windData = features.map(feature => ({
1202
1477
  windDirection: feature.properties.windDirection,
@@ -1205,12 +1480,10 @@ const Soundings = ({
1205
1480
  }));
1206
1481
  drawWindBarbs({
1207
1482
  svgRef,
1483
+ yScale,
1208
1484
  data: windData,
1209
1485
  width,
1210
- height,
1211
1486
  margin,
1212
- minPressure,
1213
- maxPressure,
1214
1487
  barbColor: color
1215
1488
  });
1216
1489
  });
@@ -1232,7 +1505,8 @@ const Soundings = ({
1232
1505
  tempRefd3,
1233
1506
  dewpointRefd3,
1234
1507
  windRefd3,
1235
- tooltipData
1508
+ tooltipData,
1509
+ diagramType
1236
1510
  });
1237
1511
  });
1238
1512
  }
@@ -1342,15 +1616,14 @@ const Soundings = ({
1342
1616
  * Copyright 2025 - Finnish Meteorological Institute (FMI)
1343
1617
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1344
1618
  * */
1345
- const BASE_URL = 'https://opendata.fmi.fi/edr';
1346
- const getCollectionLocations = collectionId => fetch(`${BASE_URL}/collections/${collectionId}/locations`, {
1619
+ const getCollectionLocations = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/locations`, {
1347
1620
  headers: getHeaders()
1348
1621
  }).then(handleResponse);
1349
- const getCollectionInstance = collectionId => fetch(`${BASE_URL}/collections/${collectionId}/instances`, {
1622
+ const getCollectionInstances = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/instances`, {
1350
1623
  headers: getHeaders()
1351
1624
  }).then(handleResponse);
1352
- const getEdrCollectionForLocation = (collectionId, instanceId, locationId, parameters, datetime) => {
1353
- const url = new URL(`${BASE_URL}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/locations/${locationId}`);
1625
+ const getEdrCollectionForLocation = (collectionId, baseUrl, instanceId, locationId, parameters, datetime) => {
1626
+ const url = new URL(`${baseUrl}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/locations/${locationId}`);
1354
1627
  url.searchParams.append('parameter-name', parameters.join(','));
1355
1628
  url.searchParams.append('datetime', datetime);
1356
1629
  url.searchParams.append('f', 'CoverageJSON');
@@ -1358,8 +1631,8 @@ const getEdrCollectionForLocation = (collectionId, instanceId, locationId, param
1358
1631
  headers: getHeaders()
1359
1632
  }).then(handleResponse);
1360
1633
  };
1361
- const getEdrCollectionForPosition = (collectionId, instanceId, coordinates, parameters, datetime) => {
1362
- const url = new URL(`${BASE_URL}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/position`);
1634
+ const getEdrCollectionForPosition = (collectionId, baseUrl, instanceId, coordinates, parameters, datetime) => {
1635
+ const url = new URL(`${baseUrl}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/position`);
1363
1636
  url.searchParams.append('coords', `POINT(${coordinates[0]} ${coordinates[1]})`);
1364
1637
  url.searchParams.append('parameter-name', parameters.join(','));
1365
1638
  url.searchParams.append('datetime', datetime);
@@ -1390,7 +1663,6 @@ const epsilon = 0.622; // Ratio of molecular weights of water vapour and dry air
1390
1663
  const e0 = 611.2; // Reference vapor pressure [Pa]
1391
1664
  const aConstant = 17.67;
1392
1665
  const bConstant = 243.5; // Constants for water, b in Celsius
1393
- const tKelvinOffset = 273.15; // To convert Celsius to Kelvin
1394
1666
  const gConstant = 461.5; // Gas constant ratio between water vapour and air [J/(K*kg)]
1395
1667
  const hConstant = 2.5e6; // Latent heat for water vaporization [J/kg]
1396
1668
  // Formula to use when dewpoint is not available
@@ -1412,14 +1684,40 @@ function dewpointFromRelativeHumidity(tCelsius,
1412
1684
  // air temperature [°C]
1413
1685
  relHumidity) {
1414
1686
  // Convert to Kelvin
1415
- const tKelvin = tCelsius + tKelvinOffset;
1687
+ const tKelvin = tCelsius + KELVIN;
1416
1688
  // Calculate dewpoint
1417
1689
  const dewpoint = tKelvin / (1 - tKelvin * Math.log(relHumidity * 0.01) * gConstant / hConstant);
1418
1690
  return dewpoint;
1419
1691
  }
1420
1692
  // Parsing and conversion function
1421
- function parseCoverageCollection(response) {
1693
+ function parseCoverageCollection(response, parameterMappings) {
1422
1694
  const features = [];
1695
+ const pressureArray = [];
1696
+ const tempArray = [];
1697
+ const dewpointArray = [];
1698
+ const windDirArray = [];
1699
+ const windSpeedArray = [];
1700
+ const windUArray = [];
1701
+ const windVArray = [];
1702
+ const specificHumidityArray = [];
1703
+ const relativeHumidityArray = [];
1704
+ const emtpyDataArray = {
1705
+ time: '',
1706
+ xCoord: NaN,
1707
+ yCoord: NaN,
1708
+ data: {
1709
+ pressureArray,
1710
+ tempArray,
1711
+ dewpointArray,
1712
+ windDirArray,
1713
+ windSpeedArray,
1714
+ windUArray,
1715
+ windVArray,
1716
+ specificHumidityArray,
1717
+ relativeHumidityArray
1718
+ }
1719
+ };
1720
+ const data = {};
1423
1721
  const {
1424
1722
  t: tAxis,
1425
1723
  x: xAxis,
@@ -1436,56 +1734,139 @@ function parseCoverageCollection(response) {
1436
1734
  const time = coverage.domain.axes.t?.values[0];
1437
1735
  const xCoord = coverage.domain.axes.x.values[0];
1438
1736
  const yCoord = coverage.domain.axes.y.values[0];
1737
+ const coverageIdentifier = `${time}-${xCoord}-${yCoord}`;
1738
+ if (data[coverageIdentifier] === undefined) {
1739
+ data[coverageIdentifier] = {
1740
+ ...emtpyDataArray
1741
+ };
1742
+ }
1743
+ data[coverageIdentifier].time = time;
1744
+ data[coverageIdentifier].xCoord = xCoord;
1745
+ data[coverageIdentifier].yCoord = yCoord;
1439
1746
  const zValues = coverage.domain.axes.z.values;
1440
1747
  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);
1748
+ if (data[coverageIdentifier].data.pressureArray.length === zIndex || ranges[parameterMappings.pressure]?.values[zIndex]) {
1749
+ const pressureFromRange = parseFloat(ranges[parameterMappings.pressure]?.values[zIndex]);
1750
+ const pressure = !Number.isNaN(pressureFromRange) ? pressureFromRange : parseFloat(zValues[zIndex]);
1751
+ data[coverageIdentifier].data.pressureArray[zIndex] = pressure;
1452
1752
  }
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;
1753
+ if (ranges[parameterMappings.temperature]) {
1754
+ data[coverageIdentifier].data.tempArray.push(parseFloat(ranges[parameterMappings.temperature]?.values[zIndex]));
1457
1755
  }
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);
1468
- }
1756
+ if (parameterMappings.dewpoint && ranges[parameterMappings.dewpoint]) {
1757
+ data[coverageIdentifier].data.dewpointArray.push(parseFloat(ranges[parameterMappings.dewpoint]?.values[zIndex]));
1758
+ } else if (parameterMappings.specificHumidity && ranges[parameterMappings.specificHumidity]) {
1759
+ data[coverageIdentifier].data.specificHumidityArray.push(parseFloat(ranges[parameterMappings.specificHumidity]?.values[zIndex]));
1760
+ } else if (parameterMappings.relativeHumidity && ranges[parameterMappings.relativeHumidity]) {
1761
+ data[coverageIdentifier].data.relativeHumidityArray.push(parseFloat(ranges[parameterMappings.relativeHumidity]?.values[zIndex]));
1762
+ }
1763
+ if (parameterMappings.windSpeed && ranges[parameterMappings.windSpeed]) {
1764
+ data[coverageIdentifier].data.windSpeedArray.push(parseFloat(ranges[parameterMappings.windSpeed]?.values[zIndex]));
1765
+ }
1766
+ if (parameterMappings.windDirection && ranges[parameterMappings.windDirection]) {
1767
+ data[coverageIdentifier].data.windDirArray.push(parseFloat(ranges[parameterMappings.windDirection]?.values[zIndex]));
1768
+ }
1769
+ if (parameterMappings.windU && ranges[parameterMappings.windU]) {
1770
+ data[coverageIdentifier].data.windUArray.push(parseFloat(ranges[parameterMappings.windU]?.values[zIndex]));
1771
+ }
1772
+ if (parameterMappings.windV && ranges[parameterMappings.windV]) {
1773
+ data[coverageIdentifier].data.windVArray.push(parseFloat(ranges[parameterMappings.windV]?.values[zIndex]));
1774
+ }
1775
+ });
1776
+ });
1777
+ // For now we only accept one data set per call so we only take the first one
1778
+ const firstCollectionIdentifier = Object.keys(data)[0];
1779
+ const collectionData = data[firstCollectionIdentifier].data;
1780
+ collectionData.pressureArray.forEach((pressure, index) => {
1781
+ // Extract dewpoint - use fallback calculations if no dewpoint in parameter mappings
1782
+ let dewpoint = NaN;
1783
+ if (parameterMappings.dewpoint) {
1784
+ dewpoint = collectionData.dewpointArray[index];
1785
+ } else if (parameterMappings.specificHumidity) {
1786
+ const specificHumidity = collectionData.specificHumidityArray[index];
1787
+ dewpoint = dewpointFromSpecificHumidity(specificHumidity / 1000,
1788
+ // from g/kg to kg/kg
1789
+ pressToPascal(pressure));
1790
+ } else if (parameterMappings.relativeHumidity) {
1791
+ const relativeHumidity = collectionData.relativeHumidityArray[index];
1792
+ dewpoint = dewpointFromRelativeHumidity(tempArray[index], relativeHumidity);
1793
+ }
1794
+ // Extract wind speed and direction - use fallback calculations if no speed and direction in parameter mappings
1795
+ let windSpeed = NaN;
1796
+ if (parameterMappings.windSpeed) {
1797
+ windSpeed = collectionData.windSpeedArray[index];
1798
+ } else if (parameterMappings.windU && parameterMappings.windV) {
1799
+ const windU = collectionData.windUArray[index];
1800
+ const windV = collectionData.windVArray[index];
1801
+ windSpeed = Math.sqrt(windU ** 2 + windV ** 2);
1802
+ }
1803
+ let windDirection = NaN;
1804
+ if (parameterMappings.windDirection) {
1805
+ windDirection = collectionData.windDirArray[index];
1806
+ } else if (parameterMappings.windU && parameterMappings.windV) {
1807
+ const windU = collectionData.windUArray[index];
1808
+ const windV = collectionData.windVArray[index];
1809
+ windDirection = (Math.atan2(-windU, -windV) * (180 / Math.PI) + 360) % 360;
1810
+ }
1811
+ features.push({
1812
+ type: 'Feature',
1813
+ geometry: {
1814
+ type: 'Point',
1815
+ coordinates: [data[firstCollectionIdentifier].xCoord, data[firstCollectionIdentifier].yCoord]
1816
+ },
1817
+ properties: {
1818
+ time: data[firstCollectionIdentifier].time,
1819
+ temp: tempArray[index],
1820
+ dewpoint,
1821
+ pressure: pressureArray[index],
1822
+ windSpeed,
1823
+ windDirection
1469
1824
  }
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
- });
1485
1825
  });
1486
1826
  });
1487
1827
  return features;
1488
1828
  }
1829
+ /**
1830
+ * Parse duration with repeats
1831
+ * @param dateString e.g. 'R67/2025-04-22T09:00:00Z/PT60M'
1832
+ * @returns Array of ISO datetime strings, e.g., ['2025-04-22T09:00:00.000Z', ...]
1833
+ */
1834
+ const parseDurationWithRepeats = dateString => {
1835
+ const parts = dateString.split('/');
1836
+ const numOfRepeats = Number(parts[0].substring(1));
1837
+ const start = new Date(parts[1]).getTime();
1838
+ const interval = parseISO8601IntervalToDateInterval(parts[2]).getTime();
1839
+ const iterations = range(numOfRepeats);
1840
+ const dateStrings = iterations.map(i => start + i * interval).map(millis => new Date(millis).toISOString());
1841
+ return dateStrings;
1842
+ };
1843
+ const getTimestepsFromInstance = instance => {
1844
+ const timeExtentValues = instance?.extent?.temporal?.values;
1845
+ if (!timeExtentValues) {
1846
+ return [];
1847
+ }
1848
+ try {
1849
+ // parse the time extent, if needed
1850
+ if (timeExtentValues.length === 1) {
1851
+ const timeExtent = timeExtentValues[0];
1852
+ // duration with repeats
1853
+ if (timeExtent.match(/^R\d+\/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\/\S+$/)) {
1854
+ return parseDurationWithRepeats(timeExtent);
1855
+ }
1856
+ // format without repeats
1857
+ const parseObject = new ParseISOTimeRangeDuration(timeExtent);
1858
+ const timeValues = range(parseObject.getTimeSteps()).map(timestep => parseObject.getDateAtTimeStep(timestep).toISOString());
1859
+ return timeValues;
1860
+ }
1861
+ // If more than one value, assume time extent is given as individual timesteps.
1862
+ // Pass through toISOString to ensure consistent timestamp format.
1863
+ const isoFormattedValues = timeExtentValues.map(value => new Date(value).toISOString());
1864
+ return isoFormattedValues;
1865
+ } catch (error) {
1866
+ console.warn(error);
1867
+ }
1868
+ return [];
1869
+ };
1489
1870
 
1490
1871
  /* *
1491
1872
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -1514,7 +1895,8 @@ const sortSoundingFeatures = data => {
1514
1895
  return featureB.properties.pressure - featureA.properties.pressure;
1515
1896
  });
1516
1897
  };
1517
- const constructQueryParams = (collectionId, instanceId, location, datetime, parameters) => {
1898
+ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings) => {
1899
+ const parameters = getParameterMappings(parameterMappings);
1518
1900
  if (typeof location === 'string') {
1519
1901
  // LOCATION call
1520
1902
  return {
@@ -1525,8 +1907,8 @@ const constructQueryParams = (collectionId, instanceId, location, datetime, para
1525
1907
  return [];
1526
1908
  }
1527
1909
  try {
1528
- const rawData = await getEdrCollectionForLocation(collectionId, instanceId, location, parameters, datetime);
1529
- const parsedData = parseCoverageCollection(rawData);
1910
+ const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime);
1911
+ const parsedData = parseCoverageCollection(rawData, parameterMappings);
1530
1912
  return sortSoundingFeatures(parsedData);
1531
1913
  } catch (err) {
1532
1914
  throw new Error('Error fetching EDR data collection by location');
@@ -1543,8 +1925,8 @@ const constructQueryParams = (collectionId, instanceId, location, datetime, para
1543
1925
  return [];
1544
1926
  }
1545
1927
  try {
1546
- const rawData = await getEdrCollectionForPosition(collectionId, instanceId, location, parameters, datetime);
1547
- const parsedData = parseCoverageCollection(rawData);
1928
+ const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime);
1929
+ const parsedData = parseCoverageCollection(rawData, parameterMappings);
1548
1930
  return sortSoundingFeatures(parsedData);
1549
1931
  } catch (err) {
1550
1932
  throw new Error('Error fetching EDR data collection by position');
@@ -1552,37 +1934,31 @@ const constructQueryParams = (collectionId, instanceId, location, datetime, para
1552
1934
  }
1553
1935
  };
1554
1936
  };
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
- });
1937
+ const defaultParameterMappings = {
1938
+ temperature: 'temperature',
1939
+ windDirection: 'winddirection',
1940
+ windSpeed: 'windspeedms',
1941
+ windU: 'windums',
1942
+ windV: 'windvms',
1943
+ pressure: 'pressure',
1944
+ dewpoint: 'dewpoint',
1945
+ humidity: 'humidity',
1946
+ specificHumidity: 'specifichumidity',
1947
+ relativeHumidity: 'relativehumidity'
1948
+ };
1949
+ const getParameterMappings = parameterMappings => {
1950
+ return Object.keys(parameterMappings).map(key => parameterMappings[key]);
1565
1951
  };
1566
- const parameters = ['temperature', 'WindDirection', 'WindSpeedMS', 'windUMS', 'windVMS', 'Pressure', 'DewPoint', 'humidity', 'specifichumidity'];
1567
1952
  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
1953
  const queries = useMemo(() => sources.map(({
1580
1954
  collectionId,
1581
- instanceId
1582
- }, index) => {
1583
- const queryParams = constructQueryParams(collectionId, instanceId, locations[collectionId] || '', dates[index] || '', parameters);
1955
+ url,
1956
+ instanceId,
1957
+ parameterMappings
1958
+ }) => {
1959
+ const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', datetime || '', parameterMappings || defaultParameterMappings);
1584
1960
  return queryParams;
1585
- }), [dates, datetime, locations, sources]);
1961
+ }), [datetime, locations, sources]);
1586
1962
  return useQueries({
1587
1963
  queries,
1588
1964
  combine: queryResults => {
@@ -1599,13 +1975,46 @@ const useMultipleSources = (sources, locations, datetime) => {
1599
1975
  }
1600
1976
  });
1601
1977
  };
1978
+ const useTimesteps = sourceSelections => {
1979
+ const queries = React.useMemo(() => sourceSelections.map(({
1980
+ collectionId,
1981
+ instanceId,
1982
+ url
1983
+ }) => ({
1984
+ queryKey: ['edr-soundings-instances', collectionId],
1985
+ queryFn: async () => {
1986
+ const data = await getCollectionInstances(collectionId, url);
1987
+ return data;
1988
+ },
1989
+ select: data => {
1990
+ const instance = data.instances.find(instance => instance.id === instanceId);
1991
+ return getTimestepsFromInstance(instance);
1992
+ }
1993
+ })), [sourceSelections]);
1994
+ const results = useQueries({
1995
+ queries,
1996
+ combine: queryResults => {
1997
+ const timestepArrays = queryResults.map(result => result.data).filter(steps => !!steps);
1998
+ return timestepArrays;
1999
+ }
2000
+ });
2001
+ return results;
2002
+ };
1602
2003
  const useGetCollectionsInstances = (collections, isDialogOpen) => {
1603
- const collectionIds = useMemo(() => collections?.map(collection => collection.id) || [], [collections]);
1604
- const queries = useMemo(() => collectionIds.map(id => {
2004
+ const collectionIds = useMemo(() => collections?.map(collection => {
2005
+ return {
2006
+ id: collection.id,
2007
+ url: collection.url
2008
+ };
2009
+ }) || [], [collections]);
2010
+ const queries = useMemo(() => collectionIds.map(({
2011
+ id,
2012
+ url
2013
+ }) => {
1605
2014
  return {
1606
2015
  queryKey: ['edr-soundings-instances', id],
1607
2016
  queryFn: async () => {
1608
- const data = await getCollectionInstance(id);
2017
+ const data = await getCollectionInstances(id, url);
1609
2018
  return data;
1610
2019
  },
1611
2020
  enabled: isDialogOpen
@@ -1638,6 +2047,33 @@ const useGetCollectionsInstances = (collections, isDialogOpen) => {
1638
2047
  data: dataWithCollectionInfo
1639
2048
  };
1640
2049
  };
2050
+ const useGetCollectionLocations = (collections, isDialogOpen) => {
2051
+ const edrQueries = useMemo(() => collections?.map(collection => ({
2052
+ queryKey: ['edr-locations', collection.id],
2053
+ queryFn: async () => {
2054
+ const locations = await getCollectionLocations(collection.id, collection.url);
2055
+ return {
2056
+ ...locations,
2057
+ id: collection.id,
2058
+ name: collection.name
2059
+ };
2060
+ },
2061
+ enabled: isDialogOpen
2062
+ })), [collections, isDialogOpen]);
2063
+ return useQueries({
2064
+ queries: edrQueries,
2065
+ combine: results => {
2066
+ const data = results.map(result => result.data).filter(result => !!result);
2067
+ const isPending = results.some(result => result.isPending);
2068
+ const error = results.find(result => result?.error)?.error;
2069
+ return {
2070
+ data,
2071
+ isPending,
2072
+ error
2073
+ };
2074
+ }
2075
+ });
2076
+ };
1641
2077
 
1642
2078
  /* *
1643
2079
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -1676,6 +2112,16 @@ const translateKeyOutsideComponents = (key, params = undefined) => i18n.t(key, {
1676
2112
  });
1677
2113
  const useSoundingsTranslation = () => useTranslation(ns);
1678
2114
 
2115
+ const MAX_LABEL_COUNT = 15;
2116
+ const shouldHideLabel = (nItems, index) => {
2117
+ const nthLabelsToShow = Math.ceil(nItems / MAX_LABEL_COUNT);
2118
+ return nItems > MAX_LABEL_COUNT &&
2119
+ // hide labels if too many items
2120
+ index !== nItems - 1 && (
2121
+ // not last item (always shown)
2122
+ // only show every nth label, skip label within last n/2 items so end doesn't get cluttered
2123
+ index % nthLabelsToShow !== 0 || nItems - 1 - index < nthLabelsToShow / 2);
2124
+ };
1679
2125
  const SoundingsTimeSlider = ({
1680
2126
  timesteps,
1681
2127
  selectedTime,
@@ -1684,9 +2130,9 @@ const SoundingsTimeSlider = ({
1684
2130
  const {
1685
2131
  i18n
1686
2132
  } = useSoundingsTranslation();
1687
- const marks = timesteps.filter(date => dateUtils.isValidIsoDateString(date)).map(date => ({
2133
+ const marks = timesteps.filter(date => dateUtils.isValidIsoDateString(date)).map((date, index, arr) => ({
1688
2134
  value: dateUtils.unix(dateUtils.getUtcFromString(date)),
1689
- label: dateUtils.dateToString(dateUtils.getUtcFromString(date), 'iii d. HH:mm', true, i18n.language)
2135
+ label: shouldHideLabel(arr.length, index) ? '' : dateUtils.dateToString(dateUtils.getUtcFromString(date), 'iii d. HH:mm', true, i18n.language)
1690
2136
  }));
1691
2137
  const onStepBackward = React.useCallback(() => {
1692
2138
  const currentIndex = timesteps.indexOf(selectedTime);
@@ -1701,12 +2147,15 @@ const SoundingsTimeSlider = ({
1701
2147
  return jsxs("div", {
1702
2148
  style: {
1703
2149
  display: 'flex',
1704
- marginTop: '17px'
2150
+ marginTop: '40px',
2151
+ marginRight: '4rem'
1705
2152
  },
1706
2153
  children: [jsxs("div", {
1707
2154
  style: {
1708
2155
  minWidth: '60px',
1709
- marginTop: '2px'
2156
+ marginTop: '2px',
2157
+ display: 'flex',
2158
+ gap: '4px'
1710
2159
  },
1711
2160
  children: [jsx(BackwardForwardStepButton, {
1712
2161
  isForwardStep: false,
@@ -1725,7 +2174,7 @@ const SoundingsTimeSlider = ({
1725
2174
  disabled: true
1726
2175
  }) : jsx(CustomSlider, {
1727
2176
  orientation: "horizontal",
1728
- valueLabelDisplay: "auto",
2177
+ valueLabelDisplay: "on",
1729
2178
  valueLabelFormat: value => dateUtils.dateToString(dateUtils.fromUnix(value), "yyyy iii dd MMM HH:mm 'UTC'", true, i18n.language),
1730
2179
  marks: marks,
1731
2180
  step: null,
@@ -1735,6 +2184,21 @@ const SoundingsTimeSlider = ({
1735
2184
  onChange: (_, value) => {
1736
2185
  updateSelectedTime(dateUtils.fromUnix(value).toISOString());
1737
2186
  },
2187
+ // Workaround for MUI bug where labels are just draggable area and clicking on one
2188
+ // does not necessarily select the described timestep
2189
+ slotProps: {
2190
+ markLabel: {
2191
+ onPointerDown: event => {
2192
+ event.preventDefault();
2193
+ const markIndex = event.currentTarget.getAttribute('data-index');
2194
+ const mark = marks[Number(markIndex)];
2195
+ const {
2196
+ value
2197
+ } = mark;
2198
+ updateSelectedTime(dateUtils.fromUnix(value).toISOString());
2199
+ }
2200
+ }
2201
+ },
1738
2202
  extraStyles: {
1739
2203
  margin: '0px 20px 0px 20px',
1740
2204
  '.MuiSlider-markLabel': {
@@ -1832,7 +2296,9 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
1832
2296
  if (value.isChecked === true) {
1833
2297
  return list.concat({
1834
2298
  collectionId: key,
1835
- instanceId: value.instanceId
2299
+ url: value.url,
2300
+ instanceId: value.instanceId,
2301
+ parameterMappings: value.parameterMappings
1836
2302
  });
1837
2303
  }
1838
2304
  return list;
@@ -1842,19 +2308,25 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
1842
2308
  const getSelectedSourcesAmount = createSelector(getSelectedSources, activeSources => {
1843
2309
  return activeSources.length;
1844
2310
  }, selectorMemoizationOptions);
2311
+ const getSelectedSourcesIds = createSelector(getSelectedSources, activeSources => {
2312
+ return activeSources.map(source => source.collectionId);
2313
+ }, selectorMemoizationOptions);
1845
2314
 
1846
- const SoundingsTimeSliderConnect = ({
1847
- timesteps
1848
- }) => {
2315
+ const SoundingsTimeSliderConnect = () => {
1849
2316
  const selectedTime = useSelector(getSelectedTime);
2317
+ const sourceSelections = useSelector(getSelectedSources);
1850
2318
  const dispatch = useDispatch();
1851
2319
  const updateSelectedTime = React.useCallback(selectedTime => {
1852
2320
  dispatch(soundingsActions.updateSelectedTime({
1853
2321
  selectedTime
1854
2322
  }));
1855
2323
  }, [dispatch]);
2324
+ const timesteps = useTimesteps(sourceSelections);
2325
+ const mergedTimesteps = Array.from(new Set(timesteps.flat())).sort();
2326
+ // TODO: next line should work in Storybook but does not: https://gitlab.com/opengeoweb/geoweb-assets/-/issues/4025
2327
+ // const mergedTimesteps = [...new Set(timestepArrays.flat())].sort();
1856
2328
  return jsx(SoundingsTimeSlider, {
1857
- timesteps: timesteps,
2329
+ timesteps: mergedTimesteps,
1858
2330
  selectedTime: selectedTime,
1859
2331
  updateSelectedTime: updateSelectedTime
1860
2332
  });
@@ -1947,15 +2419,14 @@ const MenuBarConnect = ({
1947
2419
  const SoundingsConnect = ({
1948
2420
  viewId
1949
2421
  }) => {
1950
- // TODO: use in follow-up: https://gitlab.com/opengeoweb/opengeoweb/-/issues/5991
1951
- // const selectedTime = useSelector(getSelectedTime);
2422
+ const selectedTime = useSelector(getSelectedTime);
1952
2423
  const sourceSelections = useSelector(getSelectedSources);
1953
2424
  const locationSelections = useSelector(getLocationSelections);
1954
2425
  const {
1955
2426
  error,
1956
2427
  data,
1957
2428
  isPending
1958
- } = useMultipleSources(sourceSelections, locationSelections);
2429
+ } = useMultipleSources(sourceSelections, locationSelections, selectedTime);
1959
2430
  return jsxs(Fragment, {
1960
2431
  children: [error && jsx(AlertBanner, {
1961
2432
  title: error.message,
@@ -1989,12 +2460,7 @@ const SoundingsConnect = ({
1989
2460
  top: 0
1990
2461
  }
1991
2462
  }
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: []
1997
- })]
2463
+ }), jsx(SoundingsTimeSliderConnect, {})]
1998
2464
  })]
1999
2465
  });
2000
2466
  };
@@ -2048,7 +2514,8 @@ const SoundingsSourceMenu = ({
2048
2514
  children: soundingsCollections && soundingsCollections.length > 0 ? soundingsCollections.map(collection => {
2049
2515
  const {
2050
2516
  isChecked,
2051
- instanceId
2517
+ instanceId,
2518
+ url
2052
2519
  } = sourceSelections[collection.id] || {};
2053
2520
  const hasInstances = collection.instances.length > 0;
2054
2521
  const selectedInstanceId = instanceId || collection.instances[0]?.id || undefined;
@@ -2061,6 +2528,7 @@ const SoundingsSourceMenu = ({
2061
2528
  children: jsx(Checkbox, {
2062
2529
  checked: !!isChecked,
2063
2530
  onChange: event => updateCollection(collection.id, {
2531
+ url,
2064
2532
  isChecked: event.target.checked,
2065
2533
  instanceId: selectedInstanceId
2066
2534
  }),
@@ -2074,6 +2542,7 @@ const SoundingsSourceMenu = ({
2074
2542
  skipLocalStyling: false,
2075
2543
  value: selectedInstanceId || t('noInstancesAvailable'),
2076
2544
  onChange: event => updateCollection(collection.id, {
2545
+ url,
2077
2546
  instanceId: event.target.value
2078
2547
  }),
2079
2548
  renderValue: value => jsx(Box, {
@@ -2139,6 +2608,20 @@ const SoundingsSourceDialogConnect = ({
2139
2608
  }));
2140
2609
  }, [dispatch]);
2141
2610
  const sourceSelections = useSelector(getSourceSelections);
2611
+ // Set mappings for each collection
2612
+ React.useEffect(() => {
2613
+ soundingsCollections?.forEach(collection => {
2614
+ dispatch(soundingsActions.updateSelectedSource({
2615
+ sourceId: collection.id,
2616
+ value: {
2617
+ url: collection.url,
2618
+ isChecked: sourceSelections[collection.id]?.isChecked || false,
2619
+ parameterMappings: collection.parameterMappings
2620
+ }
2621
+ }));
2622
+ });
2623
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2624
+ }, [dispatch, soundingsCollections]);
2142
2625
  const {
2143
2626
  data,
2144
2627
  isPending,
@@ -2159,12 +2642,15 @@ const SoundingsSourceDialogConnect = ({
2159
2642
 
2160
2643
  const LocationsMenu = ({
2161
2644
  services,
2645
+ selectedSourcesAmount,
2162
2646
  selectedLocations,
2163
2647
  isOpen,
2164
2648
  onClose,
2165
2649
  updateSelectedLocation,
2166
2650
  onMouseDown = () => {},
2167
- order = 0
2651
+ order = 0,
2652
+ isLoading,
2653
+ error
2168
2654
  }) => {
2169
2655
  const [searchFilter, setSearchFilter] = useState('');
2170
2656
  const {
@@ -2182,7 +2668,7 @@ const LocationsMenu = ({
2182
2668
  });
2183
2669
  }, [services, searchFilter]);
2184
2670
  const isChecked = (sourceId, locationId) => selectedLocations[sourceId] === locationId;
2185
- return jsx(ToolContainerDraggable, {
2671
+ return jsxs(ToolContainerDraggable, {
2186
2672
  startSize: {
2187
2673
  width: 320,
2188
2674
  height: 500
@@ -2198,7 +2684,15 @@ const LocationsMenu = ({
2198
2684
  bounds: "parent",
2199
2685
  onMouseDown: onMouseDown,
2200
2686
  order: order,
2201
- children: services.length === 0 ? jsx(Typography, {
2687
+ children: [isLoading && jsx(LinearProgress, {
2688
+ sx: {
2689
+ width: '100%',
2690
+ position: 'absolute'
2691
+ }
2692
+ }), error && jsx(AlertBanner, {
2693
+ title: error.message,
2694
+ shouldClose: true
2695
+ }), selectedSourcesAmount === 0 ? jsx(Typography, {
2202
2696
  sx: {
2203
2697
  margin: 1
2204
2698
  },
@@ -2280,7 +2774,7 @@ const LocationsMenu = ({
2280
2774
  })
2281
2775
  })
2282
2776
  }, service.id))]
2283
- })
2777
+ })]
2284
2778
  });
2285
2779
  };
2286
2780
 
@@ -2295,34 +2789,30 @@ const LocationsMenuConnect = ({
2295
2789
  setDialogOrder
2296
2790
  } = useSetupDialog(uiTypes.DialogTypes.SoundingsLocations);
2297
2791
  const locationSelections = useSelector(getLocationSelections);
2298
- const sourceSelections = useSelector(getSourceSelections);
2299
- const selectedSourceIds = useMemo(() => Object.keys(sourceSelections).filter(key => sourceSelections[key].isChecked), [sourceSelections]);
2300
- const edrQueries = useMemo(() => soundingsCollections.map(collection => ({
2301
- queryKey: ['edr-locations', collection.id],
2302
- queryFn: async () => {
2303
- const locations = await getCollectionLocations(collection.id);
2304
- return {
2305
- ...locations,
2306
- id: collection.id,
2307
- name: collection.name
2308
- };
2309
- }
2310
- })), [soundingsCollections]);
2311
- const services = useQueries({
2312
- queries: edrQueries,
2313
- combine: results => results.map(result => result.data).filter(result => !!result)
2314
- });
2792
+ const selectedSourceIds = useSelector(getSelectedSourcesIds);
2793
+ const sourcesAmount = useSelector(getSelectedSourcesAmount);
2794
+ const {
2795
+ data,
2796
+ isPending,
2797
+ error
2798
+ } = useGetCollectionLocations(soundingsCollections, isDialogOpen);
2799
+ const services = useMemo(() => {
2800
+ return data?.filter(service => selectedSourceIds.includes(service.id)) || [];
2801
+ }, [data, selectedSourceIds]);
2315
2802
  return jsx(LocationsMenu, {
2316
2803
  isOpen: isDialogOpen,
2317
2804
  onClose: onCloseDialog,
2318
- services: services.filter(service => selectedSourceIds.includes(service.id)),
2805
+ services: services,
2806
+ selectedSourcesAmount: sourcesAmount,
2319
2807
  selectedLocations: locationSelections,
2320
2808
  updateSelectedLocation: (sourceId, locationId) => void dispatch(soundingsActions.updateSelectedLocation({
2321
2809
  sourceId,
2322
2810
  locationId
2323
2811
  })),
2324
2812
  onMouseDown: setDialogOrder,
2325
- order: dialogOrder
2813
+ order: dialogOrder,
2814
+ isLoading: isPending,
2815
+ error: error
2326
2816
  });
2327
2817
  };
2328
2818