@opengeoweb/soundings 12.4.2 → 12.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -54,6 +54,119 @@ var soundingsTranslations = {
54
54
  fi: fi
55
55
  };
56
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
+ // Define pressure and temperature values for static lines
75
+ const maxPressure = 1050;
76
+ const minPressure = 150;
77
+ const staticPressureArray = [maxPressure];
78
+ const dp = -5;
79
+ const minTemperature = -40;
80
+ const maxTemperature = 40.6;
81
+ let pressure = maxPressure;
82
+ while (pressure > minPressure) {
83
+ pressure += dp;
84
+ staticPressureArray.push(pressure);
85
+ }
86
+ // Bijvoet constants
87
+ const T_KAPPA = 0.2857;
88
+ const T_KTHETA = 332.0;
89
+ const KELVIN = 273.156;
90
+ const WV_CA = 17.56881;
91
+ const WV_CB = 241.8945;
92
+ const WV_CE = 610.7;
93
+ const EPSILON = 0.622;
94
+ const R_AIR = 287.04;
95
+ const HEATCP = 1005;
96
+ const REFERENCE_PRESSURE = 1000 * 100; // 1000hPa = 100000Pa
97
+
98
+ /* *
99
+ * Licensed under the Apache License, Version 2.0 (the "License");
100
+ * you may not use this file except in compliance with the License.
101
+ * You may obtain a copy of the License at
102
+ *
103
+ * http://www.apache.org/licenses/LICENSE-2.0
104
+ *
105
+ * Unless required by applicable law or agreed to in writing, software
106
+ * distributed under the License is distributed on an "AS IS" BASIS,
107
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
108
+ * See the License for the specific language governing permissions and
109
+ * limitations under the License.
110
+ *
111
+ * Copyright 2025 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
112
+ * Copyright 2025 - Finnish Meteorological Institute (FMI)
113
+ * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
114
+ * */
115
+ const tempToKelvin = value => value + KELVIN;
116
+ // Calculate pressure in Pa instead of hPa
117
+ const pressToPascal = value => value * 100;
118
+ const bijvoetInterpolationTheta = (minRange, maxRange) => {
119
+ return t => {
120
+ const min = minTemperature + KELVIN;
121
+ const max = maxTemperature + KELVIN;
122
+ 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);
123
+ return minRange * (1 - xCoord) + maxRange * xCoord;
124
+ };
125
+ };
126
+ const bijvoetInterpolationPres = (minRange, maxRange) => {
127
+ return t => {
128
+ const yCoord = (pressToPascal(t) ** T_KAPPA - pressToPascal(minPressure) ** T_KAPPA) / (pressToPascal(maxPressure) ** T_KAPPA - pressToPascal(minPressure) ** T_KAPPA);
129
+ return minRange * (1 - yCoord) + maxRange * yCoord;
130
+ };
131
+ };
132
+ // Returns pressure in hPa
133
+ const pressureFromPosition = y => {
134
+ return (y * (pressToPascal(maxPressure) ** T_KAPPA - pressToPascal(minPressure) ** T_KAPPA) + pressToPascal(minPressure) ** T_KAPPA) ** (1 / T_KAPPA) / 100;
135
+ };
136
+ const calculateESat = temperature => {
137
+ const tempKelvin = temperature - KELVIN;
138
+ return WV_CE * Math.exp(WV_CA * tempKelvin / (tempKelvin + WV_CB));
139
+ };
140
+ const latHeat = temperature => {
141
+ return R_AIR / EPSILON * WV_CA * WV_CB * (temperature / (temperature - KELVIN + WV_CB)) ** 2;
142
+ };
143
+ const dTdp = (pressure, temperature) => {
144
+ const eSat = calculateESat(temperature);
145
+ const Rs = EPSILON * eSat / (pressure - eSat);
146
+ const Lv = latHeat(temperature);
147
+ const dTdp = 2.0 * temperature / (7.0 * pressure);
148
+ const dTdp1 = dTdp * (1.0 + Rs * Lv / (R_AIR * temperature));
149
+ const dTdp2 = dTdp1 / (1.0 + Rs / (7.0 * EPSILON) + (Lv / temperature) ** 2 * Rs * EPSILON / (R_AIR * HEATCP));
150
+ return dTdp2;
151
+ };
152
+ const calculateThetaFromPressTemp = (pressure, temperature, basePressure) => {
153
+ const base = basePressure || REFERENCE_PRESSURE;
154
+ const dpPascal = pressure > base ? pressToPascal(-10) : pressToPascal(10); // hPa steps to Pa steps
155
+ const numberOfSteps = Math.round((base - pressure) / dpPascal) < 1 ? 1 : Math.round((base - pressure) / dpPascal);
156
+ const pstep = (base - pressure) / numberOfSteps;
157
+ let tempN = temperature;
158
+ let pressureN = pressure;
159
+ for (let n = 0; n < numberOfSteps; n += 1) {
160
+ const k1 = pstep * dTdp(pressureN, tempN);
161
+ const k2 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k1);
162
+ const k3 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k2);
163
+ const k4 = pstep * dTdp(pressureN + pstep, tempN + k3);
164
+ tempN += k1 / 6.0 + k2 / 3.0 + k3 / 3.0 + k4 / 6.0;
165
+ pressureN += pstep;
166
+ }
167
+ return tempN;
168
+ };
169
+
57
170
  /* *
58
171
  * Licensed under the Apache License, Version 2.0 (the "License");
59
172
  * you may not use this file except in compliance with the License.
@@ -73,22 +186,25 @@ var soundingsTranslations = {
73
186
  * */
74
187
  const isobarValues = [200, 300, 400, 500, 700, 850, 925, 1000];
75
188
  const drawIsobars = ({
189
+ diagramType,
76
190
  minTemperature,
77
191
  maxTemperature,
78
192
  xScale,
79
193
  yScale,
80
194
  svg
81
195
  }) => {
82
- // Calculate X and Y coordinates for each pressure level
196
+ // For Bijvoet, the x-axis is NOT temperature but theta_es
197
+ const xmin = diagramType === 'BIJVOET' ? tempToKelvin(minTemperature) : minTemperature;
198
+ const xmax = diagramType === 'BIJVOET' ? tempToKelvin(maxTemperature) : maxTemperature;
83
199
  for (const pressureVal of isobarValues) {
84
200
  const Px = [];
85
- const tempBounds = [minTemperature, maxTemperature];
201
+ const tempBounds = [xmin, xmax];
86
202
  for (const bound of tempBounds) {
87
203
  Px.push([xScale(bound), yScale(pressureVal)]);
88
204
  }
89
205
  const lineGenerator = d3.line();
90
206
  const isoPathString = lineGenerator(Px);
91
- const strID = `${pressureVal}textId`;
207
+ const strID = `${pressureVal}-isobar`;
92
208
  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
209
  }
94
210
  };
@@ -116,37 +232,56 @@ const initializeSvg = ({
116
232
  svgRef,
117
233
  width,
118
234
  height,
119
- margin
235
+ margin,
236
+ diagramType
120
237
  }) => {
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)');
238
+ const diagram = diagramType === 'BIJVOET' ? 'bijvoet' : 'skewT';
239
+ 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})`);
240
+ svg.append('clipPath').attr('id', `clip-${diagram}d3`).append('rect').attr('width', width).attr('height', height).attr('x', 0).attr('y', 0);
241
+ 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
242
  return svg;
125
243
  };
244
+ const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
245
+ if (diagramType === 'BIJVOET') {
246
+ // Note, for Bijvoet, the x-axis is NOT temperature but theta_es
247
+ // Both axis aren't linear nor logarithmic, hence we need an interpolation function to determine (x,y) based on (theta_es,p)
248
+ // These scales work the same as for SkewT -> you pass in a pressure (or theta value) and you receive the location in the svg
249
+ const xScale = d3.scaleLinear().domain([0, 1]).range([0, width]).interpolate(bijvoetInterpolationTheta);
250
+ const yScale = d3.scaleLinear().domain([1, 0]).range([height, 0]).interpolate(bijvoetInterpolationPres);
251
+ return [xScale, yScale];
252
+ }
253
+ const xScale = d3.scaleLinear().domain([minTemperature, maxTemperature]).range([0, width]);
254
+ const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
255
+ return [xScale, yScale];
256
+ };
126
257
  const drawAxes = ({
127
258
  svg,
128
259
  xScale,
129
260
  yScale,
130
- height
261
+ height,
262
+ diagramType
131
263
  }) => {
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
- });
264
+ // For Bijvoet, we don't show an x-axis as we're interested in the isotherms only, not theta
265
+ if (diagramType !== 'BIJVOET') {
266
+ // Create x-axis with intervals
267
+ const xTicks = xScale.ticks(10);
268
+ const xMiddleValue = xTicks[Math.floor(xTicks.length / 2)];
269
+ svg.append('g').attr('type', 'x-axis').attr('transform', `translate(0,${height})`).call(d3.axisBottom(xScale).tickValues(xTicks).tickSizeInner(0).tickSizeOuter(0));
270
+ // Reformat in the case of the middle tick label
271
+ // to add the unit part
272
+ svg.selectAll('g[type="x-axis"] .tick').filter(function () {
273
+ return parseInt(d3.select(this).select('text').text(), 10) === xMiddleValue;
274
+ }).each(function () {
275
+ const unitPart = '°C';
276
+ d3.select(this).select('text')
277
+ // tspan necessary to use a different font-size
278
+ .append('tspan').text(unitPart).attr('font-size', '11px').attr('dy', '0.04em');
279
+ });
280
+ }
146
281
  // Create y-axis with intervals
147
282
  // Make sure all zeroes are printed (1000 vs 1k)
148
283
  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');
284
+ 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
285
  };
151
286
  const addLegends = ({
152
287
  svg,
@@ -157,6 +292,15 @@ const addLegends = ({
157
292
  // Add a legend for the dewpoint line
158
293
  svg.append('text').attr('x', width - 137).attr('y', 40).attr('fill', SOUNDING_COLORS[0]).text('Dewpoint (°C)');
159
294
  };
295
+ const mapToPlot = (diagramType, filteredPressureValues, filteredTemperatureValues, xScale, yScale, height) => {
296
+ if (diagramType === 'BIJVOET') {
297
+ const thetaValues = filteredTemperatureValues.map((tempValue, index) => calculateThetaFromPressTemp(pressToPascal(filteredPressureValues[index]), tempToKelvin(tempValue)));
298
+ return d3.zip(filteredPressureValues, thetaValues).map(([pressure, theta]) => [xScale(theta), yScale(pressure)]);
299
+ }
300
+ const Px = d3.zip(filteredPressureValues, filteredTemperatureValues).map(([pressure, temperature]) => [xScale(temperature), yScale(pressure)]);
301
+ // Skew/shear transformation for skew-t
302
+ return Px.map(([x, y]) => [x + height - y, y]);
303
+ };
160
304
  const plotSounding = ({
161
305
  temperatureValues,
162
306
  pressureValues,
@@ -165,16 +309,16 @@ const plotSounding = ({
165
309
  xScale,
166
310
  yScale,
167
311
  height,
168
- svg
312
+ svg,
313
+ diagramType
169
314
  }) => {
315
+ const diagram = diagramType === 'BIJVOET' ? 'bijvoet' : 'skewT';
170
316
  const [filteredPressureValues, filteredTemperatureValues] = filterDomainRangeForParameter(pressureValues, temperatureValues);
171
317
  // 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]);
318
+ const Px = mapToPlot(diagramType, filteredPressureValues, filteredTemperatureValues, xScale, yScale, height);
175
319
  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)');
320
+ const pathString = lineGenerator(Px);
321
+ 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
322
  };
179
323
  // Use this function to filter the pressure and parameter array so no NaN or null values are included
180
324
  const filterDomainRangeForParameter = (pressureDomain, parameterDomain) => {
@@ -248,6 +392,13 @@ const getParamHtml = (paramValue, colorIndex, unit, isLastElement, noDistance =
248
392
  }
249
393
  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
394
  };
395
+ const extractPressureFromY = (diagramType, y, yScale) => {
396
+ if (diagramType === 'BIJVOET') {
397
+ const frac = yScale.invert(y);
398
+ return pressureFromPosition(frac);
399
+ }
400
+ return yScale.invert(y);
401
+ };
251
402
  const drawSoundingsTooltip = ({
252
403
  event,
253
404
  yScale,
@@ -258,14 +409,15 @@ const drawSoundingsTooltip = ({
258
409
  windRefd3,
259
410
  margin,
260
411
  width,
261
- tooltipData
412
+ tooltipData,
413
+ diagramType
262
414
  }) => {
263
415
  if (tooltipData.length === 0) {
264
416
  return;
265
417
  }
266
418
  const [, y] = d3.pointer(event);
267
419
  const pressureY = event.clientY - svgRectangle.top - 10;
268
- const pressure = yScale.invert(y);
420
+ const pressure = extractPressureFromY(diagramType, y, yScale);
269
421
  const interpolatedData = tooltipData.map(dataSet => {
270
422
  return {
271
423
  hoveredPressure: dataSet.interpolatePressure(pressure),
@@ -658,12 +810,10 @@ const extendXAxis = (path, horizontalBarbMargin) => {
658
810
  };
659
811
  const drawWindBarbs = ({
660
812
  svgRef,
813
+ yScale,
661
814
  data,
662
815
  width,
663
- height,
664
816
  margin,
665
- minPressure,
666
- maxPressure,
667
817
  barbColor = 'black'
668
818
  }) => {
669
819
  const barbsSize = 25;
@@ -690,7 +840,6 @@ const drawWindBarbs = ({
690
840
  d3.select(this).attr('d', newD);
691
841
  });
692
842
  svg.attr('width', currentWidth + margin.left + horizontalBarbMargin);
693
- const pressureScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]).clamp(true);
694
843
  const barbGroup = svg.append('g').attr('class', 'wind-group').attr('transform',
695
844
  // This translation should be revisited when implementing
696
845
  // the plotting of several datasets at once
@@ -718,7 +867,7 @@ const drawWindBarbs = ({
718
867
  const cx = bbox.x + bbox.width / 2;
719
868
  const cy = bbox.y + bbox.height / 2;
720
869
  const rotation = 180 + data.windDirection;
721
- const y = pressureScale(data.pressure);
870
+ const y = yScale(data.pressure);
722
871
  // Place items in the correct vertical position
723
872
  // Correcting for barbSize
724
873
  group.attr('transform', `translate(0, ${y - barbsSize * scalingFactor / 8})`);
@@ -777,7 +926,7 @@ const getWindSymbol = value => {
777
926
  const CP$1 = 1.03e3;
778
927
  const Po = 1000.0;
779
928
  const dryAdiabaticLapserate = (theta, pressure, temperature, dp) => {
780
- const Tt = (theta + 273.15) * (Po / pressure) ** (-287 / CP$1) - 273.15;
929
+ const Tt = (theta + KELVIN) * (Po / pressure) ** (-287 / CP$1) - KELVIN;
781
930
  const dt = Tt - temperature;
782
931
  return [dp, dt];
783
932
  };
@@ -808,6 +957,31 @@ const drawDryAdiabats = ({
808
957
  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
958
  }
810
959
  };
960
+ const tempPresDryAiabat = (pressure, pres0, temp0) => temp0 * (pressure / pres0) ** (2.0 / 7.0);
961
+ const drawDryAdiabatsBijvoet = ({
962
+ staticPressureArray,
963
+ xScale,
964
+ yScale,
965
+ svg
966
+ }) => {
967
+ for (let Tbase = -20; Tbase < 40; Tbase += 5) {
968
+ const Px = [];
969
+ const tempKelvin = tempToKelvin(Tbase);
970
+ const tempDry = calculateThetaFromPressTemp(REFERENCE_PRESSURE, tempKelvin, 1000);
971
+ for (const pressure of staticPressureArray) {
972
+ const pres = pressToPascal(pressure); // Pressure in Pa
973
+ const tempPresDry = tempPresDryAiabat(pres, 1000, tempDry);
974
+ const theta = calculateThetaFromPressTemp(pres, tempPresDry);
975
+ const Ppx = yScale(pressure);
976
+ const Tpx = xScale(theta);
977
+ Px.push([Tpx, Ppx]);
978
+ }
979
+ const lineGenerator = d3.line();
980
+ const daPathString = lineGenerator(Px);
981
+ const strID = `${Tbase}-dryadiabat`;
982
+ 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)');
983
+ }
984
+ };
811
985
 
812
986
  /* *
813
987
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -853,6 +1027,35 @@ const drawIsohumes = ({
853
1027
  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
1028
  }
855
1029
  };
1030
+ const isohumeValuesBijvoet = [1, 2, 4, 7, 11, 16, 22, 29];
1031
+ const mixr2Tdew = (mixr, pressure) => {
1032
+ const mixingRation = mixr < 1e-6 ? 1e-6 : mixr;
1033
+ const eVap = pressure * mixingRation / (EPSILON + mixingRation);
1034
+ return WV_CB / (WV_CA / Math.log(eVap / WV_CE) - 1.0) + KELVIN;
1035
+ };
1036
+ const drawIsohumesBijvoet = ({
1037
+ staticPressureArray,
1038
+ height,
1039
+ xScale,
1040
+ yScale,
1041
+ svg
1042
+ }) => {
1043
+ for (const isohumeVal of isohumeValuesBijvoet) {
1044
+ const Px = [];
1045
+ const mixr = isohumeVal * 1e-3;
1046
+ for (const pressureVal of staticPressureArray) {
1047
+ const pres = pressToPascal(pressureVal); // Pressure in Pa
1048
+ const Tdew = mixr2Tdew(mixr, pres);
1049
+ const theta = calculateThetaFromPressTemp(pres, Tdew, REFERENCE_PRESSURE);
1050
+ Px.push([xScale(theta), yScale(pressureVal)]);
1051
+ }
1052
+ const lineGenerator = d3.line();
1053
+ const pathStringQ = lineGenerator(Px);
1054
+ const strID = `${isohumeVal}isohume`;
1055
+ 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)');
1056
+ 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');
1057
+ }
1058
+ };
856
1059
 
857
1060
  /* *
858
1061
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -892,6 +1095,32 @@ const drawIsotherms = ({
892
1095
  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
1096
  }
894
1097
  };
1098
+ const drawIsothermsBijvoet = ({
1099
+ staticPressureArray,
1100
+ xScale,
1101
+ yScale,
1102
+ svg,
1103
+ height
1104
+ }) => {
1105
+ // lines of constant temperature (10 degree interval from -80...+40C)
1106
+ for (let temp = -150; temp <= 40; temp += 2) {
1107
+ const points = [];
1108
+ // start locatie van elke isotherm
1109
+ const Tiso = tempToKelvin(temp);
1110
+ for (const pressure of staticPressureArray) {
1111
+ const theta = calculateThetaFromPressTemp(pressToPascal(pressure), Tiso, REFERENCE_PRESSURE);
1112
+ points.push([xScale(theta), yScale(pressure)]);
1113
+ }
1114
+ const lineGenerator = d3.line();
1115
+ const pathString = lineGenerator(points);
1116
+ const strID = `${temp}-isotherm-bijvoet`;
1117
+ 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)');
1118
+ // only show value every 10 degrees and if isotherm reaches bottom of plot
1119
+ if (temp % 10 === 0 && points[0] && points[0][1] === height) {
1120
+ 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');
1121
+ }
1122
+ }
1123
+ };
895
1124
 
896
1125
  /* *
897
1126
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -911,7 +1140,6 @@ const drawIsotherms = ({
911
1140
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
912
1141
  * */
913
1142
  const CP = 1.03e3;
914
- const KELVIN = 273.15;
915
1143
  const L_CONST = 2.5e6;
916
1144
  const RD = 287.0;
917
1145
  const RV = 461.0;
@@ -987,6 +1215,26 @@ const drawMoistAdiabats = ({
987
1215
  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
1216
  }
989
1217
  };
1218
+ const drawMoistAdiabatsBijvoet = ({
1219
+ minPressure,
1220
+ maxPressure,
1221
+ xScale,
1222
+ yScale,
1223
+ svg
1224
+ }) => {
1225
+ for (let Tbase = -20; Tbase < 40; Tbase += 5) {
1226
+ const theta = Tbase + KELVIN;
1227
+ const Px = [];
1228
+ const pressureBounds = [minPressure, maxPressure];
1229
+ for (const bound of pressureBounds) {
1230
+ Px.push([xScale(theta), yScale(bound)]);
1231
+ }
1232
+ const lineGenerator = d3.line();
1233
+ const isoPathString = lineGenerator(Px);
1234
+ const strID = `${Tbase}-moistadiabat`;
1235
+ 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)');
1236
+ }
1237
+ };
990
1238
 
991
1239
  /* *
992
1240
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -1006,6 +1254,7 @@ const drawMoistAdiabats = ({
1006
1254
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1007
1255
  * */
1008
1256
  const drawStaticLines = ({
1257
+ diagramType,
1009
1258
  dp,
1010
1259
  staticPressureArray,
1011
1260
  minTemperature,
@@ -1019,47 +1268,78 @@ const drawStaticLines = ({
1019
1268
  svg
1020
1269
  }) => {
1021
1270
  drawIsobars({
1271
+ diagramType,
1022
1272
  minTemperature,
1023
1273
  maxTemperature,
1024
1274
  xScale,
1025
1275
  yScale,
1026
1276
  svg
1027
1277
  });
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
- });
1278
+ if (diagramType === 'BIJVOET') {
1279
+ drawMoistAdiabatsBijvoet({
1280
+ minPressure,
1281
+ maxPressure,
1282
+ xScale,
1283
+ yScale,
1284
+ svg
1285
+ });
1286
+ drawIsothermsBijvoet({
1287
+ staticPressureArray,
1288
+ xScale,
1289
+ yScale,
1290
+ svg,
1291
+ height
1292
+ });
1293
+ drawDryAdiabatsBijvoet({
1294
+ staticPressureArray,
1295
+ xScale,
1296
+ yScale,
1297
+ svg
1298
+ });
1299
+ drawIsohumesBijvoet({
1300
+ staticPressureArray,
1301
+ height,
1302
+ xScale,
1303
+ yScale,
1304
+ svg
1305
+ });
1306
+ } else {
1307
+ drawIsotherms({
1308
+ staticPressureArray,
1309
+ height,
1310
+ xScale,
1311
+ yScale,
1312
+ svg
1313
+ });
1314
+ drawMoistAdiabats({
1315
+ staticPressureArray,
1316
+ minTemperature,
1317
+ maxTemperature,
1318
+ minPressure,
1319
+ maxPressure,
1320
+ width,
1321
+ height,
1322
+ xScale,
1323
+ yScale,
1324
+ svg,
1325
+ dp
1326
+ });
1327
+ drawDryAdiabats({
1328
+ staticPressureArray,
1329
+ height,
1330
+ xScale,
1331
+ yScale,
1332
+ svg,
1333
+ dp
1334
+ });
1335
+ drawIsohumes({
1336
+ staticPressureArray,
1337
+ height,
1338
+ xScale,
1339
+ yScale,
1340
+ svg
1341
+ });
1342
+ }
1063
1343
  };
1064
1344
 
1065
1345
  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 +1370,11 @@ const soundingsStyle = {
1090
1370
  fontWeight: 'bold'
1091
1371
  }
1092
1372
  };
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
1373
  const Soundings = ({
1106
1374
  data,
1107
1375
  svgDimensions,
1108
- showTooltip = true
1376
+ showTooltip = true,
1377
+ diagramType = 'SKEWT'
1109
1378
  }) => {
1110
1379
  const svgRef = useRef(null);
1111
1380
  const canvasRef = useRef(null);
@@ -1139,21 +1408,23 @@ const Soundings = ({
1139
1408
  svgRef,
1140
1409
  width,
1141
1410
  height,
1142
- margin
1411
+ margin,
1412
+ diagramType
1143
1413
  });
1144
1414
  const svgRectangle = svgRef.current.getBoundingClientRect();
1145
1415
  // 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]);
1416
+ const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature);
1148
1417
  // Add axis'
1149
1418
  drawAxes({
1150
1419
  svg,
1151
1420
  xScale,
1152
1421
  yScale,
1153
- height
1422
+ height,
1423
+ diagramType
1154
1424
  });
1155
1425
  // Draw static lines
1156
1426
  drawStaticLines({
1427
+ diagramType,
1157
1428
  dp,
1158
1429
  staticPressureArray,
1159
1430
  minTemperature,
@@ -1184,7 +1455,8 @@ const Soundings = ({
1184
1455
  xScale,
1185
1456
  yScale,
1186
1457
  height,
1187
- svg
1458
+ svg,
1459
+ diagramType
1188
1460
  });
1189
1461
  const dewpointValues = features.map(feature => feature.properties.dewpoint);
1190
1462
  // Dewpoint
@@ -1196,7 +1468,8 @@ const Soundings = ({
1196
1468
  xScale,
1197
1469
  yScale,
1198
1470
  height,
1199
- svg
1471
+ svg,
1472
+ diagramType
1200
1473
  });
1201
1474
  const windData = features.map(feature => ({
1202
1475
  windDirection: feature.properties.windDirection,
@@ -1205,12 +1478,10 @@ const Soundings = ({
1205
1478
  }));
1206
1479
  drawWindBarbs({
1207
1480
  svgRef,
1481
+ yScale,
1208
1482
  data: windData,
1209
1483
  width,
1210
- height,
1211
1484
  margin,
1212
- minPressure,
1213
- maxPressure,
1214
1485
  barbColor: color
1215
1486
  });
1216
1487
  });
@@ -1232,7 +1503,8 @@ const Soundings = ({
1232
1503
  tempRefd3,
1233
1504
  dewpointRefd3,
1234
1505
  windRefd3,
1235
- tooltipData
1506
+ tooltipData,
1507
+ diagramType
1236
1508
  });
1237
1509
  });
1238
1510
  }
@@ -1342,15 +1614,14 @@ const Soundings = ({
1342
1614
  * Copyright 2025 - Finnish Meteorological Institute (FMI)
1343
1615
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
1344
1616
  * */
1345
- const BASE_URL = 'https://opendata.fmi.fi/edr';
1346
- const getCollectionLocations = collectionId => fetch(`${BASE_URL}/collections/${collectionId}/locations`, {
1617
+ const getCollectionLocations = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/locations`, {
1347
1618
  headers: getHeaders()
1348
1619
  }).then(handleResponse);
1349
- const getCollectionInstance = collectionId => fetch(`${BASE_URL}/collections/${collectionId}/instances`, {
1620
+ const getCollectionInstance = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/instances`, {
1350
1621
  headers: getHeaders()
1351
1622
  }).then(handleResponse);
1352
- const getEdrCollectionForLocation = (collectionId, instanceId, locationId, parameters, datetime) => {
1353
- const url = new URL(`${BASE_URL}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/locations/${locationId}`);
1623
+ const getEdrCollectionForLocation = (collectionId, baseUrl, instanceId, locationId, parameters, datetime) => {
1624
+ const url = new URL(`${baseUrl}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/locations/${locationId}`);
1354
1625
  url.searchParams.append('parameter-name', parameters.join(','));
1355
1626
  url.searchParams.append('datetime', datetime);
1356
1627
  url.searchParams.append('f', 'CoverageJSON');
@@ -1358,8 +1629,8 @@ const getEdrCollectionForLocation = (collectionId, instanceId, locationId, param
1358
1629
  headers: getHeaders()
1359
1630
  }).then(handleResponse);
1360
1631
  };
1361
- const getEdrCollectionForPosition = (collectionId, instanceId, coordinates, parameters, datetime) => {
1362
- const url = new URL(`${BASE_URL}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/position`);
1632
+ const getEdrCollectionForPosition = (collectionId, baseUrl, instanceId, coordinates, parameters, datetime) => {
1633
+ const url = new URL(`${baseUrl}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/position`);
1363
1634
  url.searchParams.append('coords', `POINT(${coordinates[0]} ${coordinates[1]})`);
1364
1635
  url.searchParams.append('parameter-name', parameters.join(','));
1365
1636
  url.searchParams.append('datetime', datetime);
@@ -1390,7 +1661,6 @@ const epsilon = 0.622; // Ratio of molecular weights of water vapour and dry air
1390
1661
  const e0 = 611.2; // Reference vapor pressure [Pa]
1391
1662
  const aConstant = 17.67;
1392
1663
  const bConstant = 243.5; // Constants for water, b in Celsius
1393
- const tKelvinOffset = 273.15; // To convert Celsius to Kelvin
1394
1664
  const gConstant = 461.5; // Gas constant ratio between water vapour and air [J/(K*kg)]
1395
1665
  const hConstant = 2.5e6; // Latent heat for water vaporization [J/kg]
1396
1666
  // Formula to use when dewpoint is not available
@@ -1412,13 +1682,13 @@ function dewpointFromRelativeHumidity(tCelsius,
1412
1682
  // air temperature [°C]
1413
1683
  relHumidity) {
1414
1684
  // Convert to Kelvin
1415
- const tKelvin = tCelsius + tKelvinOffset;
1685
+ const tKelvin = tCelsius + KELVIN;
1416
1686
  // Calculate dewpoint
1417
1687
  const dewpoint = tKelvin / (1 - tKelvin * Math.log(relHumidity * 0.01) * gConstant / hConstant);
1418
1688
  return dewpoint;
1419
1689
  }
1420
1690
  // Parsing and conversion function
1421
- function parseCoverageCollection(response) {
1691
+ function parseCoverageCollection(response, parameterMappings) {
1422
1692
  const features = [];
1423
1693
  const {
1424
1694
  t: tAxis,
@@ -1438,34 +1708,38 @@ function parseCoverageCollection(response) {
1438
1708
  const yCoord = coverage.domain.axes.y.values[0];
1439
1709
  const zValues = coverage.domain.axes.z.values;
1440
1710
  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);
1711
+ const temp = parseFloat(ranges[parameterMappings.temperature]?.values[zIndex]);
1712
+ const pressureFromRange = parseFloat(ranges[parameterMappings.pressure]?.values[zIndex]);
1713
+ const pressure = !Number.isNaN(pressureFromRange) ? pressureFromRange : parseFloat(zValues[zIndex]);
1714
+ // Extract dewpoint - use fallback calculations if no dewpoint in parameter mappings
1715
+ let dewpoint = NaN;
1716
+ if (parameterMappings.dewpoint) {
1717
+ dewpoint = parseFloat(ranges[parameterMappings.dewpoint]?.values[zIndex]);
1718
+ } else if (parameterMappings.specificHumidity) {
1719
+ const specificHumidity = parseFloat(ranges[parameterMappings.specificHumidity]?.values[zIndex]);
1720
+ dewpoint = dewpointFromSpecificHumidity(specificHumidity / 1000,
1721
+ // from g/kg to kg/kg
1722
+ pressToPascal(pressure));
1723
+ } else if (parameterMappings.relativeHumidity) {
1724
+ const relativeHumidity = parseFloat(ranges[parameterMappings.relativeHumidity]?.values[zIndex]);
1725
+ dewpoint = dewpointFromRelativeHumidity(temp, relativeHumidity);
1452
1726
  }
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;
1727
+ // Extract wind speed and direction - use fallback calculations if no speed and direction in parameter mappings
1728
+ let windSpeed = NaN;
1729
+ if (parameterMappings.windSpeed) {
1730
+ windSpeed = parseFloat(ranges[parameterMappings.windSpeed]?.values[zIndex]);
1731
+ } else if (parameterMappings.windU && parameterMappings.windV) {
1732
+ const windU = parseFloat(ranges[parameterMappings.windU]?.values[zIndex]);
1733
+ const windV = parseFloat(ranges[parameterMappings.windV]?.values[zIndex]);
1734
+ windSpeed = Math.sqrt(windU ** 2 + windV ** 2);
1457
1735
  }
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
- }
1736
+ let windDirection = NaN;
1737
+ if (parameterMappings.windDirection) {
1738
+ windDirection = parseFloat(ranges[parameterMappings.windDirection]?.values[zIndex]);
1739
+ } else if (parameterMappings.windU && parameterMappings.windV) {
1740
+ const windU = parseFloat(ranges[parameterMappings.windU]?.values[zIndex]);
1741
+ const windV = parseFloat(ranges[parameterMappings.windV]?.values[zIndex]);
1742
+ windDirection = (Math.atan2(-windU, -windV) * (180 / Math.PI) + 360) % 360;
1469
1743
  }
1470
1744
  features.push({
1471
1745
  type: 'Feature',
@@ -1514,7 +1788,8 @@ const sortSoundingFeatures = data => {
1514
1788
  return featureB.properties.pressure - featureA.properties.pressure;
1515
1789
  });
1516
1790
  };
1517
- const constructQueryParams = (collectionId, instanceId, location, datetime, parameters) => {
1791
+ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings) => {
1792
+ const parameters = getParameterMappings(parameterMappings);
1518
1793
  if (typeof location === 'string') {
1519
1794
  // LOCATION call
1520
1795
  return {
@@ -1525,8 +1800,8 @@ const constructQueryParams = (collectionId, instanceId, location, datetime, para
1525
1800
  return [];
1526
1801
  }
1527
1802
  try {
1528
- const rawData = await getEdrCollectionForLocation(collectionId, instanceId, location, parameters, datetime);
1529
- const parsedData = parseCoverageCollection(rawData);
1803
+ const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime);
1804
+ const parsedData = parseCoverageCollection(rawData, parameterMappings);
1530
1805
  return sortSoundingFeatures(parsedData);
1531
1806
  } catch (err) {
1532
1807
  throw new Error('Error fetching EDR data collection by location');
@@ -1543,8 +1818,8 @@ const constructQueryParams = (collectionId, instanceId, location, datetime, para
1543
1818
  return [];
1544
1819
  }
1545
1820
  try {
1546
- const rawData = await getEdrCollectionForPosition(collectionId, instanceId, location, parameters, datetime);
1547
- const parsedData = parseCoverageCollection(rawData);
1821
+ const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime);
1822
+ const parsedData = parseCoverageCollection(rawData, parameterMappings);
1548
1823
  return sortSoundingFeatures(parsedData);
1549
1824
  } catch (err) {
1550
1825
  throw new Error('Error fetching EDR data collection by position');
@@ -1552,35 +1827,51 @@ const constructQueryParams = (collectionId, instanceId, location, datetime, para
1552
1827
  }
1553
1828
  };
1554
1829
  };
1555
- const useFakeTime = (collectionId, instanceId) => {
1830
+ const useFakeTime = (collectionId, baseUrl, instanceId) => {
1556
1831
  return useQuery({
1557
1832
  queryKey: ['temporary-instance-call', collectionId, instanceId],
1558
1833
  queryFn: async () => {
1559
- return fetch(`https://opendata.fmi.fi/edr/collections/${collectionId}/instances/${instanceId}`, {
1834
+ return fetch(`${baseUrl}/collections/${collectionId}/instances/${instanceId}`, {
1560
1835
  headers: getHeaders()
1561
1836
  }).then(handleResponse).then(data => collectionId === undefined || data === undefined ? '' : data.extent.temporal.interval[0][0]);
1562
1837
  },
1563
1838
  enabled: !!collectionId && !!instanceId
1564
1839
  });
1565
1840
  };
1566
- const parameters = ['temperature', 'WindDirection', 'WindSpeedMS', 'windUMS', 'windVMS', 'Pressure', 'DewPoint', 'humidity', 'specifichumidity'];
1841
+ const defaultParameterMappings = {
1842
+ temperature: 'temperature',
1843
+ windDirection: 'winddirection',
1844
+ windSpeed: 'windspeedms',
1845
+ windU: 'windums',
1846
+ windV: 'windvms',
1847
+ pressure: 'pressure',
1848
+ dewpoint: 'dewpoint',
1849
+ humidity: 'humidity',
1850
+ specificHumidity: 'specifichumidity',
1851
+ relativeHumidity: 'relativehumidity'
1852
+ };
1853
+ const getParameterMappings = parameterMappings => {
1854
+ return Object.keys(parameterMappings).map(key => parameterMappings[key]);
1855
+ };
1567
1856
  const useMultipleSources = (sources, locations, datetime) => {
1568
1857
  // TODO: Remove once we pass in the datetime coming from the timeslider!
1569
1858
  const {
1570
1859
  data: date0
1571
- } = useFakeTime(sources[0]?.collectionId, sources[0]?.instanceId);
1860
+ } = useFakeTime(sources[0]?.collectionId, sources[0]?.url, sources[0]?.instanceId);
1572
1861
  const {
1573
1862
  data: date1
1574
- } = useFakeTime(sources[1]?.collectionId, sources[1]?.instanceId);
1863
+ } = useFakeTime(sources[1]?.collectionId, sources[1]?.url, sources[1]?.instanceId);
1575
1864
  const {
1576
1865
  data: date2
1577
- } = useFakeTime(sources[2]?.collectionId, sources[2]?.instanceId);
1866
+ } = useFakeTime(sources[2]?.collectionId, sources[2]?.url, sources[2]?.instanceId);
1578
1867
  const dates = useMemo(() => [date0, date1, date2], [date0, date1, date2]);
1579
1868
  const queries = useMemo(() => sources.map(({
1580
1869
  collectionId,
1581
- instanceId
1870
+ url,
1871
+ instanceId,
1872
+ parameterMappings
1582
1873
  }, index) => {
1583
- const queryParams = constructQueryParams(collectionId, instanceId, locations[collectionId] || '', dates[index] || '', parameters);
1874
+ const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dates[index] || '', parameterMappings || defaultParameterMappings);
1584
1875
  return queryParams;
1585
1876
  }), [dates, datetime, locations, sources]);
1586
1877
  return useQueries({
@@ -1600,12 +1891,20 @@ const useMultipleSources = (sources, locations, datetime) => {
1600
1891
  });
1601
1892
  };
1602
1893
  const useGetCollectionsInstances = (collections, isDialogOpen) => {
1603
- const collectionIds = useMemo(() => collections?.map(collection => collection.id) || [], [collections]);
1604
- const queries = useMemo(() => collectionIds.map(id => {
1894
+ const collectionIds = useMemo(() => collections?.map(collection => {
1895
+ return {
1896
+ id: collection.id,
1897
+ url: collection.url
1898
+ };
1899
+ }) || [], [collections]);
1900
+ const queries = useMemo(() => collectionIds.map(({
1901
+ id,
1902
+ url
1903
+ }) => {
1605
1904
  return {
1606
1905
  queryKey: ['edr-soundings-instances', id],
1607
1906
  queryFn: async () => {
1608
- const data = await getCollectionInstance(id);
1907
+ const data = await getCollectionInstance(id, url);
1609
1908
  return data;
1610
1909
  },
1611
1910
  enabled: isDialogOpen
@@ -1638,6 +1937,33 @@ const useGetCollectionsInstances = (collections, isDialogOpen) => {
1638
1937
  data: dataWithCollectionInfo
1639
1938
  };
1640
1939
  };
1940
+ const useGetCollectionLocations = (collections, isDialogOpen) => {
1941
+ const edrQueries = useMemo(() => collections?.map(collection => ({
1942
+ queryKey: ['edr-locations', collection.id],
1943
+ queryFn: async () => {
1944
+ const locations = await getCollectionLocations(collection.id, collection.url);
1945
+ return {
1946
+ ...locations,
1947
+ id: collection.id,
1948
+ name: collection.name
1949
+ };
1950
+ },
1951
+ enabled: isDialogOpen
1952
+ })), [collections, isDialogOpen]);
1953
+ return useQueries({
1954
+ queries: edrQueries,
1955
+ combine: results => {
1956
+ const data = results.map(result => result.data).filter(result => !!result);
1957
+ const isPending = results.some(result => result.isPending);
1958
+ const error = results.find(result => result?.error)?.error;
1959
+ return {
1960
+ data,
1961
+ isPending,
1962
+ error
1963
+ };
1964
+ }
1965
+ });
1966
+ };
1641
1967
 
1642
1968
  /* *
1643
1969
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -1832,7 +2158,9 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
1832
2158
  if (value.isChecked === true) {
1833
2159
  return list.concat({
1834
2160
  collectionId: key,
1835
- instanceId: value.instanceId
2161
+ url: value.url,
2162
+ instanceId: value.instanceId,
2163
+ parameterMappings: value.parameterMappings
1836
2164
  });
1837
2165
  }
1838
2166
  return list;
@@ -1842,6 +2170,9 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
1842
2170
  const getSelectedSourcesAmount = createSelector(getSelectedSources, activeSources => {
1843
2171
  return activeSources.length;
1844
2172
  }, selectorMemoizationOptions);
2173
+ const getSelectedSourcesIds = createSelector(getSelectedSources, activeSources => {
2174
+ return activeSources.map(source => source.collectionId);
2175
+ }, selectorMemoizationOptions);
1845
2176
 
1846
2177
  const SoundingsTimeSliderConnect = ({
1847
2178
  timesteps
@@ -2139,6 +2470,20 @@ const SoundingsSourceDialogConnect = ({
2139
2470
  }));
2140
2471
  }, [dispatch]);
2141
2472
  const sourceSelections = useSelector(getSourceSelections);
2473
+ // Set mappings for each collection
2474
+ React.useEffect(() => {
2475
+ soundingsCollections?.forEach(collection => {
2476
+ dispatch(soundingsActions.updateSelectedSource({
2477
+ sourceId: collection.id,
2478
+ value: {
2479
+ url: collection.url,
2480
+ isChecked: sourceSelections[collection.id]?.isChecked || false,
2481
+ parameterMappings: collection.parameterMappings
2482
+ }
2483
+ }));
2484
+ });
2485
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2486
+ }, [dispatch, soundingsCollections]);
2142
2487
  const {
2143
2488
  data,
2144
2489
  isPending,
@@ -2159,12 +2504,15 @@ const SoundingsSourceDialogConnect = ({
2159
2504
 
2160
2505
  const LocationsMenu = ({
2161
2506
  services,
2507
+ selectedSourcesAmount,
2162
2508
  selectedLocations,
2163
2509
  isOpen,
2164
2510
  onClose,
2165
2511
  updateSelectedLocation,
2166
2512
  onMouseDown = () => {},
2167
- order = 0
2513
+ order = 0,
2514
+ isLoading,
2515
+ error
2168
2516
  }) => {
2169
2517
  const [searchFilter, setSearchFilter] = useState('');
2170
2518
  const {
@@ -2182,7 +2530,7 @@ const LocationsMenu = ({
2182
2530
  });
2183
2531
  }, [services, searchFilter]);
2184
2532
  const isChecked = (sourceId, locationId) => selectedLocations[sourceId] === locationId;
2185
- return jsx(ToolContainerDraggable, {
2533
+ return jsxs(ToolContainerDraggable, {
2186
2534
  startSize: {
2187
2535
  width: 320,
2188
2536
  height: 500
@@ -2198,7 +2546,15 @@ const LocationsMenu = ({
2198
2546
  bounds: "parent",
2199
2547
  onMouseDown: onMouseDown,
2200
2548
  order: order,
2201
- children: services.length === 0 ? jsx(Typography, {
2549
+ children: [isLoading && jsx(LinearProgress, {
2550
+ sx: {
2551
+ width: '100%',
2552
+ position: 'absolute'
2553
+ }
2554
+ }), error && jsx(AlertBanner, {
2555
+ title: error.message,
2556
+ shouldClose: true
2557
+ }), selectedSourcesAmount === 0 ? jsx(Typography, {
2202
2558
  sx: {
2203
2559
  margin: 1
2204
2560
  },
@@ -2280,7 +2636,7 @@ const LocationsMenu = ({
2280
2636
  })
2281
2637
  })
2282
2638
  }, service.id))]
2283
- })
2639
+ })]
2284
2640
  });
2285
2641
  };
2286
2642
 
@@ -2295,34 +2651,30 @@ const LocationsMenuConnect = ({
2295
2651
  setDialogOrder
2296
2652
  } = useSetupDialog(uiTypes.DialogTypes.SoundingsLocations);
2297
2653
  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
- });
2654
+ const selectedSourceIds = useSelector(getSelectedSourcesIds);
2655
+ const sourcesAmount = useSelector(getSelectedSourcesAmount);
2656
+ const {
2657
+ data,
2658
+ isPending,
2659
+ error
2660
+ } = useGetCollectionLocations(soundingsCollections, isDialogOpen);
2661
+ const services = useMemo(() => {
2662
+ return data?.filter(service => selectedSourceIds.includes(service.id)) || [];
2663
+ }, [data, selectedSourceIds]);
2315
2664
  return jsx(LocationsMenu, {
2316
2665
  isOpen: isDialogOpen,
2317
2666
  onClose: onCloseDialog,
2318
- services: services.filter(service => selectedSourceIds.includes(service.id)),
2667
+ services: services,
2668
+ selectedSourcesAmount: sourcesAmount,
2319
2669
  selectedLocations: locationSelections,
2320
2670
  updateSelectedLocation: (sourceId, locationId) => void dispatch(soundingsActions.updateSelectedLocation({
2321
2671
  sourceId,
2322
2672
  locationId
2323
2673
  })),
2324
2674
  onMouseDown: setDialogOrder,
2325
- order: dialogOrder
2675
+ order: dialogOrder,
2676
+ isLoading: isPending,
2677
+ error: error
2326
2678
  });
2327
2679
  };
2328
2680