@opengeoweb/soundings 12.5.0 → 12.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
+ import React, { useRef, useEffect, useMemo, useState, Fragment as Fragment$1 } from 'react';
2
3
  import { useSelector, useDispatch } from 'react-redux';
3
- import { Card, Box, Stack, LinearProgress, List, ListItem, ListItemIcon, Checkbox, MenuItem, ListItemText, Typography, Grid, ListItemButton } from '@mui/material';
4
+ import { Box, Stack, Menu, ListSubheader, Typography, MenuItem, LinearProgress, Card, List, ListItem, ListItemIcon, Checkbox, ListItemText, Grid, ListItemButton } from '@mui/material';
4
5
  import { safelyInitI18nForTestsAndStories, sharedTranslations, SHARED_NAMESPACE, dateUtils, CustomSlider, CustomToggleButton, AlertBanner, ToolContainerDraggable, StyledSelect, SearchField, CustomAccordion } from '@opengeoweb/shared';
5
- import React, { useRef, useEffect, useMemo, useState, Fragment as Fragment$1 } from 'react';
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, minBy } 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';
@@ -21,7 +23,11 @@ var en = {
21
23
  noInstancesAvailable: "No instances found",
22
24
  noServicesFound: "No source available",
23
25
  locations: "Locations",
24
- "locations-menu-warning": "You have to choose your sources in the sources menu"
26
+ "locations-menu-warning": "You have to choose your sources in the sources menu",
27
+ options: "Options",
28
+ "diagram-SKEWT": "Skew-T",
29
+ "diagram-BIJVOET": "Bijvoet",
30
+ "diagram-EMAGRAM": "Emagram"
25
31
  };
26
32
  var no = {
27
33
  sources: "Kilder",
@@ -29,7 +35,11 @@ var no = {
29
35
  noInstancesAvailable: "Ingen instanser funnet",
30
36
  noServicesFound: "Ingen tjenester funnet",
31
37
  locations: "Lokasjoner",
32
- "locations-menu-warning": "Du må velge kilder i kilde-menyen"
38
+ "locations-menu-warning": "Du må velge kilder i kilde-menyen",
39
+ options: "Valg",
40
+ "diagram-SKEWT": "Skew-T",
41
+ "diagram-BIJVOET": "Bijvoet",
42
+ "diagram-EMAGRAM": "Emagram"
33
43
  };
34
44
  var nl = {
35
45
  sources: "Bronnen",
@@ -37,7 +47,11 @@ var nl = {
37
47
  noInstancesAvailable: "Geen exemplaren gevonden",
38
48
  noServicesFound: "Geen bron beschikbaar",
39
49
  locations: "Locaties",
40
- "locations-menu-warning": "Selecteer eerst een bron in het bron menu"
50
+ "locations-menu-warning": "Selecteer eerst een bron in het bron menu",
51
+ options: "Opties",
52
+ "diagram-SKEWT": "Skew-T",
53
+ "diagram-BIJVOET": "Bijvoet",
54
+ "diagram-EMAGRAM": "Emagram"
41
55
  };
42
56
  var fi = {
43
57
  sources: "Lähteet",
@@ -45,7 +59,11 @@ var fi = {
45
59
  noInstancesAvailable: "Tapauksia ei löytynyt",
46
60
  noServicesFound: "Lähdettä ei ole saatavilla",
47
61
  locations: "Locations",
48
- "locations-menu-warning": "Valitse ensin lähde lähdevalikosta"
62
+ "locations-menu-warning": "Valitse ensin lähde lähdevalikosta",
63
+ options: "Valinnat",
64
+ "diagram-SKEWT": "Skew-T",
65
+ "diagram-BIJVOET": "Bijvoet",
66
+ "diagram-EMAGRAM": "Emagram"
49
67
  };
50
68
  var soundingsTranslations = {
51
69
  en: en,
@@ -156,7 +174,7 @@ const calculateThetaFromPressTemp = (pressure, temperature, basePressure) => {
156
174
  const pstep = (base - pressure) / numberOfSteps;
157
175
  let tempN = temperature;
158
176
  let pressureN = pressure;
159
- for (let n = 0; n < numberOfSteps; n += 1) {
177
+ for (let step = 0; step < numberOfSteps; step += 1) {
160
178
  const k1 = pstep * dTdp(pressureN, tempN);
161
179
  const k2 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k1);
162
180
  const k3 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k2);
@@ -227,6 +245,22 @@ const drawIsobars = ({
227
245
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
228
246
  * */
229
247
  const SOUNDING_COLORS = ['#4487f3', '#c3213c', '#509a00'];
248
+ const getDiagramName = (t, diagramType) => {
249
+ switch (diagramType) {
250
+ case 'BIJVOET':
251
+ {
252
+ return t('diagram-BIJVOET');
253
+ }
254
+ case 'EMAGRAM':
255
+ {
256
+ return t('diagram-EMAGRAM');
257
+ }
258
+ default:
259
+ {
260
+ return t('diagram-SKEWT');
261
+ }
262
+ }
263
+ };
230
264
  // vfx code
231
265
  const initializeSvg = ({
232
266
  svgRef,
@@ -235,10 +269,9 @@ const initializeSvg = ({
235
269
  margin,
236
270
  diagramType
237
271
  }) => {
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)`);
272
+ const svg = d3.select(svgRef.current).attr('width', width + (margin.left + margin.right)).attr('height', height + (margin.top + margin.bottom)).attr('id', diagramType).attr('data-testid', diagramType).append('g').attr('transform', `translate(${margin.left + margin.right},${margin.top})`);
273
+ svg.append('clipPath').attr('id', `clip-${diagramType}d3`).append('rect').attr('width', width).attr('height', height).attr('x', 0).attr('y', 0);
274
+ svg.append('rect').attr('height', height).attr('width', width).attr('fill', '#ffffff00').attr('opacity', 1).attr('id', `${diagramType}d3`).attr('clip-path', `url(#clip-${diagramType}d3)`);
242
275
  return svg;
243
276
  };
244
277
  const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
@@ -269,9 +302,9 @@ const drawAxes = ({
269
302
  svg.append('g').attr('type', 'x-axis').attr('transform', `translate(0,${height})`).call(d3.axisBottom(xScale).tickValues(xTicks).tickSizeInner(0).tickSizeOuter(0));
270
303
  // Reformat in the case of the middle tick label
271
304
  // to add the unit part
272
- svg.selectAll('g[type="x-axis"] .tick').filter(function () {
305
+ svg.selectAll('g[type="x-axis"] .tick').filter(function isMiddle() {
273
306
  return parseInt(d3.select(this).select('text').text(), 10) === xMiddleValue;
274
- }).each(function () {
307
+ }).each(function txt() {
275
308
  const unitPart = '°C';
276
309
  d3.select(this).select('text')
277
310
  // tspan necessary to use a different font-size
@@ -281,7 +314,7 @@ const drawAxes = ({
281
314
  // Create y-axis with intervals
282
315
  // Make sure all zeroes are printed (1000 vs 1k)
283
316
  const formatTicks = d3.format('.0f');
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');
317
+ svg.append('g').attr('type', 'y-axis').call(d3.axisLeft(yScale).tickValues(isobarValues).tickFormat(formatTicks).tickSizeInner(0).tickSizeOuter(0)).call(gee => gee.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');
285
318
  };
286
319
  const addLegends = ({
287
320
  svg,
@@ -298,6 +331,9 @@ const mapToPlot = (diagramType, filteredPressureValues, filteredTemperatureValue
298
331
  return d3.zip(filteredPressureValues, thetaValues).map(([pressure, theta]) => [xScale(theta), yScale(pressure)]);
299
332
  }
300
333
  const Px = d3.zip(filteredPressureValues, filteredTemperatureValues).map(([pressure, temperature]) => [xScale(temperature), yScale(pressure)]);
334
+ if (diagramType === 'EMAGRAM') {
335
+ return Px.map(([x, y]) => [x, y]);
336
+ }
301
337
  // Skew/shear transformation for skew-t
302
338
  return Px.map(([x, y]) => [x + height - y, y]);
303
339
  };
@@ -312,17 +348,16 @@ const plotSounding = ({
312
348
  svg,
313
349
  diagramType
314
350
  }) => {
315
- const diagram = diagramType === 'BIJVOET' ? 'bijvoet' : 'skewT';
316
351
  const [filteredPressureValues, filteredTemperatureValues] = filterDomainRangeForParameter(pressureValues, temperatureValues);
317
352
  // Map temp and pressure to pixels
318
353
  const Px = mapToPlot(diagramType, filteredPressureValues, filteredTemperatureValues, xScale, yScale, height);
319
354
  const lineGenerator = d3.line();
320
355
  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)`);
356
+ 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-${diagramType}d3)`);
322
357
  };
323
358
  // Use this function to filter the pressure and parameter array so no NaN or null values are included
324
359
  const filterDomainRangeForParameter = (pressureDomain, parameterDomain) => {
325
- const filteredPressureDomain = pressureDomain.filter((value, index) => !isNaN(parameterDomain[index]) && parameterDomain[index] !== null);
360
+ const filteredPressureDomain = pressureDomain.filter((_value, index) => !isNaN(parameterDomain[index]) && parameterDomain[index] !== null);
326
361
  const filteredParameterDomain = parameterDomain.filter(value => !isNaN(value) && value !== null);
327
362
  return [filteredPressureDomain, filteredParameterDomain];
328
363
  };
@@ -828,16 +863,16 @@ const drawWindBarbs = ({
828
863
  return;
829
864
  }
830
865
  // Add extra width to all isobars needed for the windbarbs
831
- d3.selectAll('path[type="isobar"]').each(function () {
832
- const path = d3.select(this).attr('d');
866
+ d3.selectAll('path[type="isobar"]').each((_d, i, nodes) => {
867
+ const path = d3.select(nodes[i]).attr('d');
833
868
  const newD = extendIsoBar(path, horizontalBarbMargin);
834
- d3.select(this).attr('d', newD).attr('clip-path', null);
869
+ d3.select(nodes[i]).attr('d', newD).attr('clip-path', null);
835
870
  });
836
871
  // Add extra width to x-axis needed for the windbarbs
837
- d3.select('g[type="x-axis"] path.domain').each(function () {
838
- const path = d3.select(this).attr('d');
872
+ d3.select('g[type="x-axis"] path.domain').each((_d, i, nodes) => {
873
+ const path = d3.select(nodes[i]).attr('d');
839
874
  const newD = extendXAxis(path, horizontalBarbMargin);
840
- d3.select(this).attr('d', newD);
875
+ d3.select(nodes[i]).attr('d', newD);
841
876
  });
842
877
  svg.attr('width', currentWidth + margin.left + horizontalBarbMargin);
843
878
  const barbGroup = svg.append('g').attr('class', 'wind-group').attr('transform',
@@ -855,8 +890,8 @@ const drawWindBarbs = ({
855
890
  innerGroup.selectAll('*').attr('stroke', barbColor);
856
891
  // Only update the fill for the paths that already used a fill
857
892
  // in their original svg form.
858
- innerGroup.selectAll('*').each(function () {
859
- const el = d3.select(this);
893
+ innerGroup.selectAll('*').each((_d, i, nodes) => {
894
+ const el = d3.select(nodes[i]);
860
895
  const currentFill = el.attr('fill');
861
896
  if (currentFill && currentFill !== 'none') {
862
897
  el.attr('fill', barbColor);
@@ -936,7 +971,8 @@ const drawDryAdiabats = ({
936
971
  xScale,
937
972
  yScale,
938
973
  svg,
939
- dp
974
+ dp,
975
+ diagramType
940
976
  }) => {
941
977
  for (let Tbase = -40; Tbase < 240; Tbase += 10) {
942
978
  let Pnew = staticPressureArray[0];
@@ -948,13 +984,15 @@ const drawDryAdiabats = ({
948
984
  Pnew += DPDT[0];
949
985
  Px.push([xScale(Tnew), yScale(pressure)]);
950
986
  }
951
- // Skew/shear transformation for skew-t
952
- for (const PxElement of Px) {
953
- PxElement[0] = PxElement[0] + height - PxElement[1];
987
+ if (diagramType === 'SKEWT') {
988
+ // Skew/shear transformation for skew-t
989
+ for (const PxElement of Px) {
990
+ PxElement[0] = PxElement[0] + height - PxElement[1];
991
+ }
954
992
  }
955
993
  const lineGenerator = d3.line();
956
994
  const daPathString = lineGenerator(Px);
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)');
995
+ 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-${diagramType}d3)`);
958
996
  }
959
997
  };
960
998
  const tempPresDryAiabat = (pressure, pres0, temp0) => temp0 * (pressure / pres0) ** (2.0 / 7.0);
@@ -979,7 +1017,7 @@ const drawDryAdiabatsBijvoet = ({
979
1017
  const lineGenerator = d3.line();
980
1018
  const daPathString = lineGenerator(Px);
981
1019
  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)');
1020
+ 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
1021
  }
984
1022
  };
985
1023
 
@@ -1006,7 +1044,8 @@ const drawIsohumes = ({
1006
1044
  height,
1007
1045
  xScale,
1008
1046
  yScale,
1009
- svg
1047
+ svg,
1048
+ diagramType
1010
1049
  }) => {
1011
1050
  for (const isohumeVal of isohumeValues) {
1012
1051
  const Px = [];
@@ -1016,14 +1055,16 @@ const drawIsohumes = ({
1016
1055
  const temp = 237.3 * logthing / (17.269 - logthing);
1017
1056
  Px.push([xScale(temp), yScale(pressureVal)]);
1018
1057
  }
1019
- // Skew/shear transformation for skew-t
1020
- for (const PxElement of Px) {
1021
- PxElement[0] = PxElement[0] + height - PxElement[1];
1058
+ if (diagramType === 'SKEWT') {
1059
+ // Skew/shear transformation for skew-t
1060
+ for (const PxElement of Px) {
1061
+ PxElement[0] = PxElement[0] + height - PxElement[1];
1062
+ }
1022
1063
  }
1023
1064
  const lineGenerator = d3.line();
1024
1065
  const pathStringQ = lineGenerator(Px);
1025
1066
  const strID = `${isohumeVal}isohume`;
1026
- 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-skewTd3)');
1067
+ 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-${diagramType}d3)`);
1027
1068
  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');
1028
1069
  }
1029
1070
  };
@@ -1052,7 +1093,7 @@ const drawIsohumesBijvoet = ({
1052
1093
  const lineGenerator = d3.line();
1053
1094
  const pathStringQ = lineGenerator(Px);
1054
1095
  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)');
1096
+ 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
1097
  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
1098
  }
1058
1099
  };
@@ -1079,7 +1120,8 @@ const drawIsotherms = ({
1079
1120
  height,
1080
1121
  xScale,
1081
1122
  yScale,
1082
- svg
1123
+ svg,
1124
+ diagramType
1083
1125
  }) => {
1084
1126
  // lines of constant temperature (10 degree interval from -80...+40C)
1085
1127
  for (let temp = -80; temp <= 40; temp += 10) {
@@ -1087,12 +1129,11 @@ const drawIsotherms = ({
1087
1129
  for (const pressure of staticPressureArray) {
1088
1130
  const Ppx = yScale(pressure);
1089
1131
  const Tpx = xScale(temp);
1090
- const skewedX = Tpx + (height - Ppx);
1091
- points.push([skewedX, Ppx]);
1132
+ points.push([diagramType === 'SKEWT' ? Tpx + (height - Ppx) : Tpx, Ppx]);
1092
1133
  }
1093
1134
  const lineGenerator = d3.line();
1094
1135
  const pathString = lineGenerator(points);
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)');
1136
+ 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-${diagramType}d3)`);
1096
1137
  }
1097
1138
  };
1098
1139
  const drawIsothermsBijvoet = ({
@@ -1113,8 +1154,8 @@ const drawIsothermsBijvoet = ({
1113
1154
  }
1114
1155
  const lineGenerator = d3.line();
1115
1156
  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)');
1157
+ const strID = `${temp}-isotherm-BIJVOET`;
1158
+ 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
1159
  // only show value every 10 degrees and if isotherm reaches bottom of plot
1119
1160
  if (temp % 10 === 0 && points[0] && points[0][1] === height) {
1120
1161
  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');
@@ -1143,7 +1184,7 @@ const CP = 1.03e3;
1143
1184
  const L_CONST = 2.5e6;
1144
1185
  const RD = 287.0;
1145
1186
  const RV = 461.0;
1146
- const moistAdiabaticLapserate = (minTemp, pressure, temperature, dp) => {
1187
+ const moistAdiabaticLapserate = (pressure, temperature, dp) => {
1147
1188
  const kelvin = temperature + KELVIN;
1148
1189
  const lsbc = L_CONST / RV * (1.0 / KELVIN - 1.0 / kelvin);
1149
1190
  const rw = 6.11 * Math.exp(lsbc) * (0.622 / pressure);
@@ -1165,7 +1206,8 @@ const drawMoistAdiabats = ({
1165
1206
  xScale,
1166
1207
  yScale,
1167
1208
  svg,
1168
- dp
1209
+ dp,
1210
+ diagramType
1169
1211
  }) => {
1170
1212
  /* We need the moist adiabat to cross the isotherm at 1000hPa
1171
1213
  Therefore we start the calculations from 1000hPa and work upwards (towards 0)
@@ -1182,19 +1224,21 @@ const drawMoistAdiabats = ({
1182
1224
  // Then calculate the rest of the moist adiabat for pressures <1000hPa
1183
1225
  for (const pressure of staticPressureArray) {
1184
1226
  if (pressure < 1000) {
1185
- const DPDT = moistAdiabaticLapserate(-80, Pnew, Tnew, dp);
1227
+ const DPDT = moistAdiabaticLapserate(Pnew, Tnew, dp);
1186
1228
  Tnew += DPDT[1];
1187
1229
  Pnew += DPDT[0];
1188
1230
  Px.push([xScale(Tnew), yScale(pressure)]);
1189
1231
  }
1190
1232
  }
1191
- for (const PxElement of Px) {
1192
- PxElement[0] = PxElement[0] + height - PxElement[1];
1233
+ if (diagramType === 'SKEWT') {
1234
+ for (const PxElement of Px) {
1235
+ PxElement[0] = PxElement[0] + height - PxElement[1];
1236
+ }
1193
1237
  }
1194
1238
  const lineGenerator = d3.line();
1195
1239
  const maPathString = lineGenerator(Px);
1196
1240
  // Draw this first section of the line
1197
- svg.append('path').attr('d', maPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'purple').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-skewTd3)');
1241
+ svg.append('path').attr('d', maPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'purple').style('stroke-opacity', 0.5).attr('clip-path', `url(#clip-${diagramType}d3)`);
1198
1242
  // Now work backwards from 1000hPa to calculate most adiabat until the bottom of the plot
1199
1243
  const PxBottom = [];
1200
1244
  PxBottom.push([Tpx1000, Ppx1000]);
@@ -1202,17 +1246,19 @@ const drawMoistAdiabats = ({
1202
1246
  let TnewBottom = Tbase;
1203
1247
  const startIndex = staticPressureArray.findIndex(value => value === 1000);
1204
1248
  for (let i = startIndex - 1; i >= 0; i -= 1) {
1205
- const DPDT = moistAdiabaticLapserate(-80, PnewBottom, TnewBottom, 5);
1249
+ const DPDT = moistAdiabaticLapserate(PnewBottom, TnewBottom, 5);
1206
1250
  TnewBottom += DPDT[1];
1207
1251
  PnewBottom += DPDT[0];
1208
1252
  PxBottom.push([xScale(TnewBottom), yScale(staticPressureArray[i])]);
1209
1253
  }
1210
- // Skew/shear transformation for skew-t
1211
- for (const PxElement of PxBottom) {
1212
- PxElement[0] = PxElement[0] + height - PxElement[1];
1254
+ if (diagramType === 'SKEWT') {
1255
+ // Skew/shear transformation for skew-t
1256
+ for (const PxElement of PxBottom) {
1257
+ PxElement[0] = PxElement[0] + height - PxElement[1];
1258
+ }
1213
1259
  }
1214
1260
  const maPathStringBottom = lineGenerator(PxBottom);
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)');
1261
+ 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-${diagramType}d3)`);
1216
1262
  }
1217
1263
  };
1218
1264
  const drawMoistAdiabatsBijvoet = ({
@@ -1232,7 +1278,7 @@ const drawMoistAdiabatsBijvoet = ({
1232
1278
  const lineGenerator = d3.line();
1233
1279
  const isoPathString = lineGenerator(Px);
1234
1280
  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)');
1281
+ 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
1282
  }
1237
1283
  };
1238
1284
 
@@ -1305,6 +1351,7 @@ const drawStaticLines = ({
1305
1351
  });
1306
1352
  } else {
1307
1353
  drawIsotherms({
1354
+ diagramType,
1308
1355
  staticPressureArray,
1309
1356
  height,
1310
1357
  xScale,
@@ -1312,6 +1359,7 @@ const drawStaticLines = ({
1312
1359
  svg
1313
1360
  });
1314
1361
  drawMoistAdiabats({
1362
+ diagramType,
1315
1363
  staticPressureArray,
1316
1364
  minTemperature,
1317
1365
  maxTemperature,
@@ -1325,6 +1373,7 @@ const drawStaticLines = ({
1325
1373
  dp
1326
1374
  });
1327
1375
  drawDryAdiabats({
1376
+ diagramType,
1328
1377
  staticPressureArray,
1329
1378
  height,
1330
1379
  xScale,
@@ -1333,6 +1382,7 @@ const drawStaticLines = ({
1333
1382
  dp
1334
1383
  });
1335
1384
  drawIsohumes({
1385
+ diagramType,
1336
1386
  staticPressureArray,
1337
1387
  height,
1338
1388
  xScale,
@@ -1387,7 +1437,7 @@ const Soundings = ({
1387
1437
  width: svgDimensions?.width || 812,
1388
1438
  height: svgDimensions?.height || 802,
1389
1439
  margin: {
1390
- top: svgDimensions?.margin?.top || 12,
1440
+ top: svgDimensions?.margin?.top || 4,
1391
1441
  right: svgDimensions?.margin?.right || 20,
1392
1442
  bottom: svgDimensions?.margin?.bottom || 25,
1393
1443
  left: svgDimensions?.margin?.left || 20
@@ -1516,7 +1566,7 @@ const Soundings = ({
1516
1566
  svg.on('mousemove', null);
1517
1567
  };
1518
1568
  // eslint-disable-next-line react-hooks/exhaustive-deps
1519
- }, [showTooltip, data]);
1569
+ }, [showTooltip, data, diagramType]);
1520
1570
  useEffect(() => {
1521
1571
  const handleMouseLeave = () => {
1522
1572
  if (pressureRef.current) {
@@ -1557,7 +1607,7 @@ const Soundings = ({
1557
1607
  windRef.current.style.display = 'none';
1558
1608
  }
1559
1609
  }, [data]);
1560
- return jsxs(Card, {
1610
+ return jsxs(Box, {
1561
1611
  ref: canvasRef,
1562
1612
  sx: {
1563
1613
  position: 'relative',
@@ -1617,7 +1667,7 @@ const Soundings = ({
1617
1667
  const getCollectionLocations = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/locations`, {
1618
1668
  headers: getHeaders()
1619
1669
  }).then(handleResponse);
1620
- const getCollectionInstance = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/instances`, {
1670
+ const getCollectionInstances = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/instances`, {
1621
1671
  headers: getHeaders()
1622
1672
  }).then(handleResponse);
1623
1673
  const getEdrCollectionForLocation = (collectionId, baseUrl, instanceId, locationId, parameters, datetime) => {
@@ -1690,6 +1740,32 @@ relHumidity) {
1690
1740
  // Parsing and conversion function
1691
1741
  function parseCoverageCollection(response, parameterMappings) {
1692
1742
  const features = [];
1743
+ const pressureArray = [];
1744
+ const tempArray = [];
1745
+ const dewpointArray = [];
1746
+ const windDirArray = [];
1747
+ const windSpeedArray = [];
1748
+ const windUArray = [];
1749
+ const windVArray = [];
1750
+ const specificHumidityArray = [];
1751
+ const relativeHumidityArray = [];
1752
+ const emtpyDataArray = {
1753
+ time: '',
1754
+ xCoord: NaN,
1755
+ yCoord: NaN,
1756
+ data: {
1757
+ pressureArray,
1758
+ tempArray,
1759
+ dewpointArray,
1760
+ windDirArray,
1761
+ windSpeedArray,
1762
+ windUArray,
1763
+ windVArray,
1764
+ specificHumidityArray,
1765
+ relativeHumidityArray
1766
+ }
1767
+ };
1768
+ const data = {};
1693
1769
  const {
1694
1770
  t: tAxis,
1695
1771
  x: xAxis,
@@ -1706,60 +1782,139 @@ function parseCoverageCollection(response, parameterMappings) {
1706
1782
  const time = coverage.domain.axes.t?.values[0];
1707
1783
  const xCoord = coverage.domain.axes.x.values[0];
1708
1784
  const yCoord = coverage.domain.axes.y.values[0];
1785
+ const coverageIdentifier = `${time}-${xCoord}-${yCoord}`;
1786
+ if (data[coverageIdentifier] === undefined) {
1787
+ data[coverageIdentifier] = {
1788
+ ...emtpyDataArray
1789
+ };
1790
+ }
1791
+ data[coverageIdentifier].time = time;
1792
+ data[coverageIdentifier].xCoord = xCoord;
1793
+ data[coverageIdentifier].yCoord = yCoord;
1709
1794
  const zValues = coverage.domain.axes.z.values;
1710
1795
  zValues.forEach((_, zIndex) => {
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);
1796
+ if (data[coverageIdentifier].data.pressureArray.length === zIndex || ranges[parameterMappings.pressure]?.values[zIndex]) {
1797
+ const pressureFromRange = parseFloat(ranges[parameterMappings.pressure]?.values[zIndex]);
1798
+ const pressure = !Number.isNaN(pressureFromRange) ? pressureFromRange : parseFloat(zValues[zIndex]);
1799
+ data[coverageIdentifier].data.pressureArray[zIndex] = pressure;
1726
1800
  }
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);
1801
+ if (ranges[parameterMappings.temperature]) {
1802
+ data[coverageIdentifier].data.tempArray.push(parseFloat(ranges[parameterMappings.temperature]?.values[zIndex]));
1735
1803
  }
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;
1804
+ if (parameterMappings.dewpoint && ranges[parameterMappings.dewpoint]) {
1805
+ data[coverageIdentifier].data.dewpointArray.push(parseFloat(ranges[parameterMappings.dewpoint]?.values[zIndex]));
1806
+ } else if (parameterMappings.specificHumidity && ranges[parameterMappings.specificHumidity]) {
1807
+ data[coverageIdentifier].data.specificHumidityArray.push(parseFloat(ranges[parameterMappings.specificHumidity]?.values[zIndex]));
1808
+ } else if (parameterMappings.relativeHumidity && ranges[parameterMappings.relativeHumidity]) {
1809
+ data[coverageIdentifier].data.relativeHumidityArray.push(parseFloat(ranges[parameterMappings.relativeHumidity]?.values[zIndex]));
1810
+ }
1811
+ if (parameterMappings.windSpeed && ranges[parameterMappings.windSpeed]) {
1812
+ data[coverageIdentifier].data.windSpeedArray.push(parseFloat(ranges[parameterMappings.windSpeed]?.values[zIndex]));
1813
+ }
1814
+ if (parameterMappings.windDirection && ranges[parameterMappings.windDirection]) {
1815
+ data[coverageIdentifier].data.windDirArray.push(parseFloat(ranges[parameterMappings.windDirection]?.values[zIndex]));
1816
+ }
1817
+ if (parameterMappings.windU && ranges[parameterMappings.windU]) {
1818
+ data[coverageIdentifier].data.windUArray.push(parseFloat(ranges[parameterMappings.windU]?.values[zIndex]));
1819
+ }
1820
+ if (parameterMappings.windV && ranges[parameterMappings.windV]) {
1821
+ data[coverageIdentifier].data.windVArray.push(parseFloat(ranges[parameterMappings.windV]?.values[zIndex]));
1822
+ }
1823
+ });
1824
+ });
1825
+ // For now we only accept one data set per call so we only take the first one
1826
+ const firstCollectionIdentifier = Object.keys(data)[0];
1827
+ const collectionData = data[firstCollectionIdentifier].data;
1828
+ collectionData.pressureArray.forEach((pressure, index) => {
1829
+ // Extract dewpoint - use fallback calculations if no dewpoint in parameter mappings
1830
+ let dewpoint = NaN;
1831
+ if (parameterMappings.dewpoint) {
1832
+ dewpoint = collectionData.dewpointArray[index];
1833
+ } else if (parameterMappings.specificHumidity) {
1834
+ const specificHumidity = collectionData.specificHumidityArray[index];
1835
+ dewpoint = dewpointFromSpecificHumidity(specificHumidity / 1000,
1836
+ // from g/kg to kg/kg
1837
+ pressToPascal(pressure));
1838
+ } else if (parameterMappings.relativeHumidity) {
1839
+ const relativeHumidity = collectionData.relativeHumidityArray[index];
1840
+ dewpoint = dewpointFromRelativeHumidity(tempArray[index], relativeHumidity);
1841
+ }
1842
+ // Extract wind speed and direction - use fallback calculations if no speed and direction in parameter mappings
1843
+ let windSpeed = NaN;
1844
+ if (parameterMappings.windSpeed) {
1845
+ windSpeed = collectionData.windSpeedArray[index];
1846
+ } else if (parameterMappings.windU && parameterMappings.windV) {
1847
+ const windU = collectionData.windUArray[index];
1848
+ const windV = collectionData.windVArray[index];
1849
+ windSpeed = Math.sqrt(windU ** 2 + windV ** 2);
1850
+ }
1851
+ let windDirection = NaN;
1852
+ if (parameterMappings.windDirection) {
1853
+ windDirection = collectionData.windDirArray[index];
1854
+ } else if (parameterMappings.windU && parameterMappings.windV) {
1855
+ const windU = collectionData.windUArray[index];
1856
+ const windV = collectionData.windVArray[index];
1857
+ windDirection = (Math.atan2(-windU, -windV) * (180 / Math.PI) + 360) % 360;
1858
+ }
1859
+ features.push({
1860
+ type: 'Feature',
1861
+ geometry: {
1862
+ type: 'Point',
1863
+ coordinates: [data[firstCollectionIdentifier].xCoord, data[firstCollectionIdentifier].yCoord]
1864
+ },
1865
+ properties: {
1866
+ time: data[firstCollectionIdentifier].time,
1867
+ temp: tempArray[index],
1868
+ dewpoint,
1869
+ pressure: pressureArray[index],
1870
+ windSpeed,
1871
+ windDirection
1743
1872
  }
1744
- features.push({
1745
- type: 'Feature',
1746
- geometry: {
1747
- type: 'Point',
1748
- coordinates: [xCoord, yCoord]
1749
- },
1750
- properties: {
1751
- time,
1752
- temp,
1753
- dewpoint,
1754
- pressure,
1755
- windSpeed,
1756
- windDirection
1757
- }
1758
- });
1759
1873
  });
1760
1874
  });
1761
1875
  return features;
1762
1876
  }
1877
+ /**
1878
+ * Parse duration with repeats
1879
+ * @param dateString e.g. 'R67/2025-04-22T09:00:00Z/PT60M'
1880
+ * @returns Array of ISO datetime strings, e.g., ['2025-04-22T09:00:00.000Z', ...]
1881
+ */
1882
+ const parseDurationWithRepeats = dateString => {
1883
+ const parts = dateString.split('/');
1884
+ const numOfRepeats = Number(parts[0].substring(1));
1885
+ const start = new Date(parts[1]).getTime();
1886
+ const interval = parseISO8601IntervalToDateInterval(parts[2]).getTime();
1887
+ const iterations = range(numOfRepeats);
1888
+ const dateStrings = iterations.map(i => start + i * interval).map(millis => new Date(millis).toISOString());
1889
+ return dateStrings;
1890
+ };
1891
+ const getTimestepsFromInstance = instance => {
1892
+ const timeExtentValues = instance?.extent?.temporal?.values;
1893
+ if (!timeExtentValues) {
1894
+ return [];
1895
+ }
1896
+ try {
1897
+ // parse the time extent, if needed
1898
+ if (timeExtentValues.length === 1) {
1899
+ const timeExtent = timeExtentValues[0];
1900
+ // duration with repeats
1901
+ if (timeExtent.match(/^R\d+\/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\/\S+$/)) {
1902
+ return parseDurationWithRepeats(timeExtent);
1903
+ }
1904
+ // format without repeats
1905
+ const parseObject = new ParseISOTimeRangeDuration(timeExtent);
1906
+ const timeValues = range(parseObject.getTimeSteps()).map(timestep => parseObject.getDateAtTimeStep(timestep).toISOString());
1907
+ return timeValues;
1908
+ }
1909
+ // If more than one value, assume time extent is given as individual timesteps.
1910
+ // Pass through toISOString to ensure consistent timestamp format.
1911
+ const isoFormattedValues = timeExtentValues.map(value => new Date(value).toISOString());
1912
+ return isoFormattedValues;
1913
+ } catch (error) {
1914
+ console.warn(error);
1915
+ }
1916
+ return [];
1917
+ };
1763
1918
 
1764
1919
  /* *
1765
1920
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -1795,8 +1950,8 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
1795
1950
  return {
1796
1951
  queryKey: ['edr-soundings-for-location', collectionId, instanceId || '', location, parameters.join(), datetime],
1797
1952
  queryFn: async () => {
1798
- // No collection or location selected, don't do EDR call but return empty array
1799
- if (collectionId === undefined || location === '') {
1953
+ // No collection, location or time selected, don't do EDR call but return empty array
1954
+ if (collectionId === undefined || location === '' || datetime === '') {
1800
1955
  return [];
1801
1956
  }
1802
1957
  try {
@@ -1813,8 +1968,8 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
1813
1968
  return {
1814
1969
  queryKey: ['edr-soundings-for-position', collectionId, location.join(), parameters.join(), datetime],
1815
1970
  queryFn: async () => {
1816
- // No collection or position selected, don't do EDR call but return empty array
1817
- if (collectionId === undefined || location === undefined) {
1971
+ // No collection, position or time selected, don't do EDR call but return empty array
1972
+ if (collectionId === undefined || location === undefined || datetime === '') {
1818
1973
  return [];
1819
1974
  }
1820
1975
  try {
@@ -1827,17 +1982,6 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
1827
1982
  }
1828
1983
  };
1829
1984
  };
1830
- const useFakeTime = (collectionId, baseUrl, instanceId) => {
1831
- return useQuery({
1832
- queryKey: ['temporary-instance-call', collectionId, instanceId],
1833
- queryFn: async () => {
1834
- return fetch(`${baseUrl}/collections/${collectionId}/instances/${instanceId}`, {
1835
- headers: getHeaders()
1836
- }).then(handleResponse).then(data => collectionId === undefined || data === undefined ? '' : data.extent.temporal.interval[0][0]);
1837
- },
1838
- enabled: !!collectionId && !!instanceId
1839
- });
1840
- };
1841
1985
  const defaultParameterMappings = {
1842
1986
  temperature: 'temperature',
1843
1987
  windDirection: 'winddirection',
@@ -1854,34 +1998,34 @@ const getParameterMappings = parameterMappings => {
1854
1998
  return Object.keys(parameterMappings).map(key => parameterMappings[key]);
1855
1999
  };
1856
2000
  const useMultipleSources = (sources, locations, datetime) => {
1857
- // TODO: Remove once we pass in the datetime coming from the timeslider!
1858
- const {
1859
- data: date0
1860
- } = useFakeTime(sources[0]?.collectionId, sources[0]?.url, sources[0]?.instanceId);
1861
2001
  const {
1862
- data: date1
1863
- } = useFakeTime(sources[1]?.collectionId, sources[1]?.url, sources[1]?.instanceId);
1864
- const {
1865
- data: date2
1866
- } = useFakeTime(sources[2]?.collectionId, sources[2]?.url, sources[2]?.instanceId);
1867
- const dates = useMemo(() => [date0, date1, date2], [date0, date1, date2]);
1868
- const queries = useMemo(() => sources.map(({
1869
- collectionId,
1870
- url,
1871
- instanceId,
1872
- parameterMappings
1873
- }, index) => {
1874
- const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dates[index] || '', parameterMappings || defaultParameterMappings);
1875
- return queryParams;
1876
- }), [dates, datetime, locations, sources]);
2002
+ data: timesteps,
2003
+ error: timestepsError,
2004
+ isPending: timestepsPending
2005
+ } = useTimesteps(sources);
2006
+ const queries = useMemo(() => {
2007
+ return sources.map(({
2008
+ collectionId,
2009
+ url,
2010
+ instanceId,
2011
+ parameterMappings
2012
+ }, index) => {
2013
+ // If acceptance time logic is needed, it could come here.
2014
+ const dateToUse = datetime &&
2015
+ // Only use selected time if exact match if found
2016
+ timesteps[index].includes(new Date(datetime).toISOString()) ? datetime : '';
2017
+ const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings);
2018
+ return queryParams;
2019
+ });
2020
+ }, [datetime, locations, sources, timesteps]);
1877
2021
  return useQueries({
1878
2022
  queries,
1879
2023
  combine: queryResults => {
1880
2024
  const data = queryResults.map(result => {
1881
2025
  return result.data || [];
1882
2026
  });
1883
- const isPending = queryResults.some(result => result.isPending);
1884
- const error = queryResults.find(result => result?.error)?.error;
2027
+ const isPending = timestepsPending || queryResults.some(result => result.isPending);
2028
+ const error = timestepsError || queryResults.find(result => result?.error)?.error;
1885
2029
  return {
1886
2030
  data,
1887
2031
  isPending,
@@ -1890,6 +2034,41 @@ const useMultipleSources = (sources, locations, datetime) => {
1890
2034
  }
1891
2035
  });
1892
2036
  };
2037
+ const useTimesteps = sourceSelections => {
2038
+ const queries = React.useMemo(() => sourceSelections.map(({
2039
+ collectionId,
2040
+ instanceId,
2041
+ url
2042
+ }) => ({
2043
+ queryKey: ['edr-soundings-instances', collectionId],
2044
+ queryFn: async () => {
2045
+ const data = await getCollectionInstances(collectionId, url);
2046
+ return data;
2047
+ },
2048
+ select: data => {
2049
+ const instance = data.instances.find(instance => instance.id === instanceId);
2050
+ return getTimestepsFromInstance(instance);
2051
+ }
2052
+ })), [sourceSelections]);
2053
+ const results = useQueries({
2054
+ queries,
2055
+ combine: queryResults => {
2056
+ const data = queryResults.map(result => result.data || []);
2057
+ // The underlying query is generic, so we rewrap the error to be more specific
2058
+ const foundError = queryResults.find(result => result?.error)?.error;
2059
+ const error = foundError ? new Error('Error fetching timesteps by collection', {
2060
+ cause: foundError
2061
+ }) : null;
2062
+ const isPending = queryResults.some(result => result.isPending);
2063
+ return {
2064
+ data,
2065
+ error,
2066
+ isPending
2067
+ };
2068
+ }
2069
+ });
2070
+ return results;
2071
+ };
1893
2072
  const useGetCollectionsInstances = (collections, isDialogOpen) => {
1894
2073
  const collectionIds = useMemo(() => collections?.map(collection => {
1895
2074
  return {
@@ -1904,7 +2083,7 @@ const useGetCollectionsInstances = (collections, isDialogOpen) => {
1904
2083
  return {
1905
2084
  queryKey: ['edr-soundings-instances', id],
1906
2085
  queryFn: async () => {
1907
- const data = await getCollectionInstance(id, url);
2086
+ const data = await getCollectionInstances(id, url);
1908
2087
  return data;
1909
2088
  },
1910
2089
  enabled: isDialogOpen
@@ -2002,6 +2181,16 @@ const translateKeyOutsideComponents = (key, params = undefined) => i18n.t(key, {
2002
2181
  });
2003
2182
  const useSoundingsTranslation = () => useTranslation(ns);
2004
2183
 
2184
+ const MAX_LABEL_COUNT = 15;
2185
+ const shouldHideLabel = (nItems, index) => {
2186
+ const nthLabelsToShow = Math.ceil(nItems / MAX_LABEL_COUNT);
2187
+ return nItems > MAX_LABEL_COUNT &&
2188
+ // hide labels if too many items
2189
+ index !== nItems - 1 && (
2190
+ // not last item (always shown)
2191
+ // only show every nth label, skip label within last n/2 items so end doesn't get cluttered
2192
+ index % nthLabelsToShow !== 0 || nItems - 1 - index < nthLabelsToShow / 2);
2193
+ };
2005
2194
  const SoundingsTimeSlider = ({
2006
2195
  timesteps,
2007
2196
  selectedTime,
@@ -2010,9 +2199,9 @@ const SoundingsTimeSlider = ({
2010
2199
  const {
2011
2200
  i18n
2012
2201
  } = useSoundingsTranslation();
2013
- const marks = timesteps.filter(date => dateUtils.isValidIsoDateString(date)).map(date => ({
2202
+ const marks = timesteps.filter(date => dateUtils.isValidIsoDateString(date)).map((date, index, arr) => ({
2014
2203
  value: dateUtils.unix(dateUtils.getUtcFromString(date)),
2015
- label: dateUtils.dateToString(dateUtils.getUtcFromString(date), 'iii d. HH:mm', true, i18n.language)
2204
+ label: shouldHideLabel(arr.length, index) ? '' : dateUtils.dateToString(dateUtils.getUtcFromString(date), 'iii d. HH:mm', true, i18n.language)
2016
2205
  }));
2017
2206
  const onStepBackward = React.useCallback(() => {
2018
2207
  const currentIndex = timesteps.indexOf(selectedTime);
@@ -2027,12 +2216,15 @@ const SoundingsTimeSlider = ({
2027
2216
  return jsxs("div", {
2028
2217
  style: {
2029
2218
  display: 'flex',
2030
- marginTop: '17px'
2219
+ marginTop: '40px',
2220
+ marginRight: '4rem'
2031
2221
  },
2032
2222
  children: [jsxs("div", {
2033
2223
  style: {
2034
2224
  minWidth: '60px',
2035
- marginTop: '2px'
2225
+ marginTop: '2px',
2226
+ display: 'flex',
2227
+ gap: '4px'
2036
2228
  },
2037
2229
  children: [jsx(BackwardForwardStepButton, {
2038
2230
  isForwardStep: false,
@@ -2051,7 +2243,7 @@ const SoundingsTimeSlider = ({
2051
2243
  disabled: true
2052
2244
  }) : jsx(CustomSlider, {
2053
2245
  orientation: "horizontal",
2054
- valueLabelDisplay: "auto",
2246
+ valueLabelDisplay: "on",
2055
2247
  valueLabelFormat: value => dateUtils.dateToString(dateUtils.fromUnix(value), "yyyy iii dd MMM HH:mm 'UTC'", true, i18n.language),
2056
2248
  marks: marks,
2057
2249
  step: null,
@@ -2061,6 +2253,21 @@ const SoundingsTimeSlider = ({
2061
2253
  onChange: (_, value) => {
2062
2254
  updateSelectedTime(dateUtils.fromUnix(value).toISOString());
2063
2255
  },
2256
+ // Workaround for MUI bug where labels are just draggable area and clicking on one
2257
+ // does not necessarily select the described timestep
2258
+ slotProps: {
2259
+ markLabel: {
2260
+ onPointerDown: event => {
2261
+ event.preventDefault();
2262
+ const markIndex = event.currentTarget.getAttribute('data-index');
2263
+ const mark = marks[Number(markIndex)];
2264
+ const {
2265
+ value
2266
+ } = mark;
2267
+ updateSelectedTime(dateUtils.fromUnix(value).toISOString());
2268
+ }
2269
+ }
2270
+ },
2064
2271
  extraStyles: {
2065
2272
  margin: '0px 20px 0px 20px',
2066
2273
  '.MuiSlider-markLabel': {
@@ -2174,19 +2381,41 @@ const getSelectedSourcesIds = createSelector(getSelectedSources, activeSources =
2174
2381
  return activeSources.map(source => source.collectionId);
2175
2382
  }, selectorMemoizationOptions);
2176
2383
 
2177
- const SoundingsTimeSliderConnect = ({
2178
- timesteps
2179
- }) => {
2384
+ const roundToClosestTimestep = (timeToRound, availableSteps) => {
2385
+ if (availableSteps.length > 0 && !availableSteps.includes(timeToRound)) {
2386
+ const selectionValue = dateUtils.unix(new Date(timeToRound));
2387
+ const closestTimestep = minBy(availableSteps, timeValue =>
2388
+ // distance to selected time
2389
+ Math.abs(selectionValue - dateUtils.unix(new Date(timeValue))));
2390
+ return closestTimestep || availableSteps[0];
2391
+ }
2392
+ return timeToRound;
2393
+ };
2394
+ const SoundingsTimeSliderConnect = () => {
2180
2395
  const selectedTime = useSelector(getSelectedTime);
2396
+ const sourceSelections = useSelector(getSelectedSources);
2181
2397
  const dispatch = useDispatch();
2182
2398
  const updateSelectedTime = React.useCallback(selectedTime => {
2183
2399
  dispatch(soundingsActions.updateSelectedTime({
2184
2400
  selectedTime
2185
2401
  }));
2186
2402
  }, [dispatch]);
2403
+ const {
2404
+ data: timesteps
2405
+ } = useTimesteps(sourceSelections);
2406
+ const mergedTimesteps = Array.from(new Set(timesteps.flat())).sort();
2407
+ // TODO: next line should work in Storybook but does not: https://gitlab.com/opengeoweb/geoweb-assets/-/issues/4025
2408
+ // const mergedTimesteps = [...new Set(timestepArrays.flat())].sort();
2409
+ // Ensure that the selected time is always one of the available timesteps
2410
+ const validSelectedTime = roundToClosestTimestep(selectedTime, mergedTimesteps);
2411
+ useEffect(() => {
2412
+ if (selectedTime !== validSelectedTime) {
2413
+ updateSelectedTime(validSelectedTime);
2414
+ }
2415
+ }, [selectedTime, updateSelectedTime, validSelectedTime]);
2187
2416
  return jsx(SoundingsTimeSlider, {
2188
- timesteps: timesteps,
2189
- selectedTime: selectedTime,
2417
+ timesteps: mergedTimesteps,
2418
+ selectedTime: validSelectedTime,
2190
2419
  updateSelectedTime: updateSelectedTime
2191
2420
  });
2192
2421
  };
@@ -2275,18 +2504,92 @@ const MenuBarConnect = ({
2275
2504
  });
2276
2505
  };
2277
2506
 
2507
+ const soundingDiagrams = ['SKEWT', 'BIJVOET', 'EMAGRAM'];
2508
+
2509
+ const DiagramTypeButton = ({
2510
+ diagramType,
2511
+ setDiagramType
2512
+ }) => {
2513
+ const {
2514
+ t
2515
+ } = useSoundingsTranslation();
2516
+ const [anchorEl, setAnchorEl] = React.useState(null);
2517
+ const isOpen = Boolean(anchorEl);
2518
+ const handleClick = event => {
2519
+ setAnchorEl(anchorEl ? null : event.currentTarget);
2520
+ };
2521
+ return jsxs(CustomToggleButton, {
2522
+ variant: "tool",
2523
+ selected: isOpen,
2524
+ onClick: handleClick,
2525
+ "data-testid": "soundings-diagramtype-options-toolbutton",
2526
+ disableRipple: true,
2527
+ size: "small",
2528
+ sx: {
2529
+ height: '24px !important',
2530
+ width: '75px'
2531
+ },
2532
+ role: "button",
2533
+ children: [getDiagramName(t, diagramType), jsxs(Menu, {
2534
+ autoFocus: false,
2535
+ slotProps: {
2536
+ backdrop: {
2537
+ style: {
2538
+ opacity: 0,
2539
+ transition: 'none'
2540
+ }
2541
+ }
2542
+ },
2543
+ open: isOpen,
2544
+ anchorEl: anchorEl,
2545
+ anchorOrigin: {
2546
+ vertical: 21,
2547
+ horizontal: 6
2548
+ },
2549
+ sx: {
2550
+ '& .MuiMenu-list': {
2551
+ padding: 0
2552
+ },
2553
+ '& .MuiMenuItem-root': {
2554
+ fontSize: 16
2555
+ }
2556
+ },
2557
+ children: [jsx(ListSubheader, {
2558
+ sx: {
2559
+ paddingTop: '8px'
2560
+ },
2561
+ children: jsx(Typography, {
2562
+ sx: {
2563
+ letterSpacing: '0.4px',
2564
+ fontSize: 12
2565
+ },
2566
+ children: t('options')
2567
+ })
2568
+ }), soundingDiagrams.map(id => jsx(MenuItem, {
2569
+ onClick: () => {
2570
+ setDiagramType(id);
2571
+ },
2572
+ selected: diagramType === id,
2573
+ children: getDiagramName(t, id)
2574
+ }, id))]
2575
+ })]
2576
+ });
2577
+ };
2578
+
2579
+ const size = 600;
2278
2580
  const SoundingsConnect = ({
2279
- viewId
2581
+ viewId,
2582
+ diagramType = 'SKEWT'
2280
2583
  }) => {
2281
- // TODO: use in follow-up: https://gitlab.com/opengeoweb/opengeoweb/-/issues/5991
2282
- // const selectedTime = useSelector(getSelectedTime);
2584
+ const [chosenDiagramType, setChosenDiagramType] = React.useState(diagramType);
2585
+ const selectedTime = useSelector(getSelectedTime);
2283
2586
  const sourceSelections = useSelector(getSelectedSources);
2284
2587
  const locationSelections = useSelector(getLocationSelections);
2285
2588
  const {
2286
2589
  error,
2287
2590
  data,
2288
2591
  isPending
2289
- } = useMultipleSources(sourceSelections, locationSelections);
2592
+ } = useMultipleSources(sourceSelections, locationSelections, selectedTime);
2290
2593
  return jsxs(Fragment, {
2291
2594
  children: [error && jsx(AlertBanner, {
2292
2595
  title: error.message,
@@ -2308,24 +2611,34 @@ const SoundingsConnect = ({
2308
2611
  children: jsx(MenuBarConnect, {
2309
2612
  viewId: viewId
2310
2613
  })
2311
- }), jsx(Soundings, {
2312
- data: data,
2313
- svgDimensions: {
2314
- width: 600,
2315
- height: 600,
2316
- margin: {
2317
- bottom: 0,
2318
- left: 0,
2319
- right: 0,
2320
- top: 0
2321
- }
2322
- }
2323
- }), jsx(SoundingsTimeSliderConnect
2324
- // TODO: use real timesteps https://gitlab.com/opengeoweb/opengeoweb/-/issues/5991
2325
- , {
2326
- // TODO: use real timesteps https://gitlab.com/opengeoweb/opengeoweb/-/issues/5991
2327
- timesteps: []
2328
- })]
2614
+ }), jsxs(Card, {
2615
+ children: [jsx(Box, {
2616
+ sx: {
2617
+ display: 'flex',
2618
+ justifyContent: 'flex-end',
2619
+ paddingTop: '2px',
2620
+ paddingRight: '2px',
2621
+ width: size
2622
+ },
2623
+ children: jsx(DiagramTypeButton, {
2624
+ diagramType: chosenDiagramType,
2625
+ setDiagramType: setChosenDiagramType
2626
+ })
2627
+ }), jsx(Soundings, {
2628
+ data: data,
2629
+ svgDimensions: {
2630
+ width: size,
2631
+ height: size,
2632
+ margin: {
2633
+ bottom: 0,
2634
+ left: 0,
2635
+ right: 0,
2636
+ top: 0
2637
+ }
2638
+ },
2639
+ diagramType: chosenDiagramType
2640
+ })]
2641
+ }), jsx(SoundingsTimeSliderConnect, {})]
2329
2642
  })]
2330
2643
  });
2331
2644
  };
@@ -2379,7 +2692,8 @@ const SoundingsSourceMenu = ({
2379
2692
  children: soundingsCollections && soundingsCollections.length > 0 ? soundingsCollections.map(collection => {
2380
2693
  const {
2381
2694
  isChecked,
2382
- instanceId
2695
+ instanceId,
2696
+ url
2383
2697
  } = sourceSelections[collection.id] || {};
2384
2698
  const hasInstances = collection.instances.length > 0;
2385
2699
  const selectedInstanceId = instanceId || collection.instances[0]?.id || undefined;
@@ -2392,6 +2706,7 @@ const SoundingsSourceMenu = ({
2392
2706
  children: jsx(Checkbox, {
2393
2707
  checked: !!isChecked,
2394
2708
  onChange: event => updateCollection(collection.id, {
2709
+ url,
2395
2710
  isChecked: event.target.checked,
2396
2711
  instanceId: selectedInstanceId
2397
2712
  }),
@@ -2405,6 +2720,7 @@ const SoundingsSourceMenu = ({
2405
2720
  skipLocalStyling: false,
2406
2721
  value: selectedInstanceId || t('noInstancesAvailable'),
2407
2722
  onChange: event => updateCollection(collection.id, {
2723
+ url,
2408
2724
  instanceId: event.target.value
2409
2725
  }),
2410
2726
  renderValue: value => jsx(Box, {
@@ -2682,7 +2998,8 @@ const soundingsComponentType = 'Soundings';
2682
2998
  const soundingsLookUp = payload => {
2683
2999
  if (payload.componentType === soundingsComponentType) {
2684
3000
  return jsx(SoundingsConnect, {
2685
- viewId: payload.id
3001
+ viewId: payload.id,
3002
+ diagramType: payload.diagramType ? payload.diagramType : 'SKEWT'
2686
3003
  });
2687
3004
  }
2688
3005
  return null;
@@ -2716,4 +3033,4 @@ const createMockStore = mockState => configureStore({
2716
3033
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(storeMiddlewares)
2717
3034
  });
2718
3035
 
2719
- export { LocationsMenuConnect, SOUNDINGS_NAMESPACE, SoundingsConnect, SoundingsSourceDialogConnect, createMockStore, initSoundingsI18n, soundingsComponentType, soundingsLookUp, soundingsReducersMap, soundingsTranslations, translateKeyOutsideComponents, useSoundingsTranslation };
3036
+ export { LocationsMenuConnect, SOUNDINGS_NAMESPACE, SoundingsConnect, SoundingsSourceDialogConnect, createMockStore, initSoundingsI18n, soundingDiagrams, soundingsComponentType, soundingsLookUp, soundingsReducersMap, soundingsTranslations, translateKeyOutsideComponents, useSoundingsTranslation };