@opengeoweb/soundings 12.6.0 → 12.8.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,13 +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
7
  import { useQueries } from '@tanstack/react-query';
8
8
  import { getHeaders, handleResponse } from '@opengeoweb/api';
9
9
  import { ParseISOTimeRangeDuration, parseISO8601IntervalToDateInterval } from '@opengeoweb/webmap';
10
- import { range } from 'lodash';
10
+ import { range, minBy } from 'lodash';
11
11
  import { timesliderTranslations, TIMESLIDER_NAMESPACE, BackwardForwardStepButton } from '@opengeoweb/timeslider';
12
12
  import i18n from 'i18next';
13
13
  export { default as i18n } from 'i18next';
@@ -23,7 +23,11 @@ var en = {
23
23
  noInstancesAvailable: "No instances found",
24
24
  noServicesFound: "No source available",
25
25
  locations: "Locations",
26
- "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"
27
31
  };
28
32
  var no = {
29
33
  sources: "Kilder",
@@ -31,7 +35,11 @@ var no = {
31
35
  noInstancesAvailable: "Ingen instanser funnet",
32
36
  noServicesFound: "Ingen tjenester funnet",
33
37
  locations: "Lokasjoner",
34
- "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"
35
43
  };
36
44
  var nl = {
37
45
  sources: "Bronnen",
@@ -39,7 +47,11 @@ var nl = {
39
47
  noInstancesAvailable: "Geen exemplaren gevonden",
40
48
  noServicesFound: "Geen bron beschikbaar",
41
49
  locations: "Locaties",
42
- "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"
43
55
  };
44
56
  var fi = {
45
57
  sources: "Lähteet",
@@ -47,7 +59,11 @@ var fi = {
47
59
  noInstancesAvailable: "Tapauksia ei löytynyt",
48
60
  noServicesFound: "Lähdettä ei ole saatavilla",
49
61
  locations: "Locations",
50
- "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"
51
67
  };
52
68
  var soundingsTranslations = {
53
69
  en: en,
@@ -158,7 +174,7 @@ const calculateThetaFromPressTemp = (pressure, temperature, basePressure) => {
158
174
  const pstep = (base - pressure) / numberOfSteps;
159
175
  let tempN = temperature;
160
176
  let pressureN = pressure;
161
- for (let n = 0; n < numberOfSteps; n += 1) {
177
+ for (let step = 0; step < numberOfSteps; step += 1) {
162
178
  const k1 = pstep * dTdp(pressureN, tempN);
163
179
  const k2 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k1);
164
180
  const k3 = pstep * dTdp(pressureN + 0.5 * pstep, tempN + 0.5 * k2);
@@ -229,6 +245,22 @@ const drawIsobars = ({
229
245
  * Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
230
246
  * */
231
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
+ };
232
264
  // vfx code
233
265
  const initializeSvg = ({
234
266
  svgRef,
@@ -237,10 +269,9 @@ const initializeSvg = ({
237
269
  margin,
238
270
  diagramType
239
271
  }) => {
240
- const diagram = diagramType === 'BIJVOET' ? 'bijvoet' : 'skewT';
241
- const svg = d3.select(svgRef.current).attr('width', width + (margin.left + margin.right)).attr('height', height + (margin.top + margin.bottom)).attr('id', diagram).attr('data-testid', diagram).append('g').attr('transform', `translate(${margin.left + margin.right},${margin.top})`);
242
- svg.append('clipPath').attr('id', `clip-${diagram}d3`).append('rect').attr('width', width).attr('height', height).attr('x', 0).attr('y', 0);
243
- svg.append('rect').attr('height', height).attr('width', width).attr('fill', '#ffffff00').attr('opacity', 1).attr('id', `${diagram}d3`).attr('clip-path', `url(#clip-${diagram}d3)`);
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)`);
244
275
  return svg;
245
276
  };
246
277
  const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
@@ -271,9 +302,9 @@ const drawAxes = ({
271
302
  svg.append('g').attr('type', 'x-axis').attr('transform', `translate(0,${height})`).call(d3.axisBottom(xScale).tickValues(xTicks).tickSizeInner(0).tickSizeOuter(0));
272
303
  // Reformat in the case of the middle tick label
273
304
  // to add the unit part
274
- svg.selectAll('g[type="x-axis"] .tick').filter(function () {
305
+ svg.selectAll('g[type="x-axis"] .tick').filter(function isMiddle() {
275
306
  return parseInt(d3.select(this).select('text').text(), 10) === xMiddleValue;
276
- }).each(function () {
307
+ }).each(function txt() {
277
308
  const unitPart = '°C';
278
309
  d3.select(this).select('text')
279
310
  // tspan necessary to use a different font-size
@@ -283,7 +314,7 @@ const drawAxes = ({
283
314
  // Create y-axis with intervals
284
315
  // Make sure all zeroes are printed (1000 vs 1k)
285
316
  const formatTicks = d3.format('.0f');
286
- svg.append('g').attr('type', 'y-axis').call(d3.axisLeft(yScale).tickValues(isobarValues).tickFormat(formatTicks).tickSizeInner(0).tickSizeOuter(0)).call(g => g.select('.domain').remove()).append('text').attr('fill', 'currentColor').attr('x', '0').attr('y', yScale(400)).attr('font-size', '11px').attr('dy', '-0.9em').attr('text-anchor', 'left').text('hPa');
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');
287
318
  };
288
319
  const addLegends = ({
289
320
  svg,
@@ -300,6 +331,9 @@ const mapToPlot = (diagramType, filteredPressureValues, filteredTemperatureValue
300
331
  return d3.zip(filteredPressureValues, thetaValues).map(([pressure, theta]) => [xScale(theta), yScale(pressure)]);
301
332
  }
302
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
+ }
303
337
  // Skew/shear transformation for skew-t
304
338
  return Px.map(([x, y]) => [x + height - y, y]);
305
339
  };
@@ -314,17 +348,16 @@ const plotSounding = ({
314
348
  svg,
315
349
  diagramType
316
350
  }) => {
317
- const diagram = diagramType === 'BIJVOET' ? 'bijvoet' : 'skewT';
318
351
  const [filteredPressureValues, filteredTemperatureValues] = filterDomainRangeForParameter(pressureValues, temperatureValues);
319
352
  // Map temp and pressure to pixels
320
353
  const Px = mapToPlot(diagramType, filteredPressureValues, filteredTemperatureValues, xScale, yScale, height);
321
354
  const lineGenerator = d3.line();
322
355
  const pathString = lineGenerator(Px);
323
- svg.append('path').attr('d', pathString).attr('id', 'dataline').attr('x', 0).attr('y', 0).style('fill', 'none').style('stroke-width', 2).style('stroke', color).style('stroke-opacity', 1).style('stroke-dasharray', lineStyle).attr('clip-path', `url(#clip-${diagram}d3)`);
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)`);
324
357
  };
325
358
  // Use this function to filter the pressure and parameter array so no NaN or null values are included
326
359
  const filterDomainRangeForParameter = (pressureDomain, parameterDomain) => {
327
- 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);
328
361
  const filteredParameterDomain = parameterDomain.filter(value => !isNaN(value) && value !== null);
329
362
  return [filteredPressureDomain, filteredParameterDomain];
330
363
  };
@@ -830,16 +863,16 @@ const drawWindBarbs = ({
830
863
  return;
831
864
  }
832
865
  // Add extra width to all isobars needed for the windbarbs
833
- d3.selectAll('path[type="isobar"]').each(function () {
834
- 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');
835
868
  const newD = extendIsoBar(path, horizontalBarbMargin);
836
- d3.select(this).attr('d', newD).attr('clip-path', null);
869
+ d3.select(nodes[i]).attr('d', newD).attr('clip-path', null);
837
870
  });
838
871
  // Add extra width to x-axis needed for the windbarbs
839
- d3.select('g[type="x-axis"] path.domain').each(function () {
840
- 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');
841
874
  const newD = extendXAxis(path, horizontalBarbMargin);
842
- d3.select(this).attr('d', newD);
875
+ d3.select(nodes[i]).attr('d', newD);
843
876
  });
844
877
  svg.attr('width', currentWidth + margin.left + horizontalBarbMargin);
845
878
  const barbGroup = svg.append('g').attr('class', 'wind-group').attr('transform',
@@ -857,8 +890,8 @@ const drawWindBarbs = ({
857
890
  innerGroup.selectAll('*').attr('stroke', barbColor);
858
891
  // Only update the fill for the paths that already used a fill
859
892
  // in their original svg form.
860
- innerGroup.selectAll('*').each(function () {
861
- const el = d3.select(this);
893
+ innerGroup.selectAll('*').each((_d, i, nodes) => {
894
+ const el = d3.select(nodes[i]);
862
895
  const currentFill = el.attr('fill');
863
896
  if (currentFill && currentFill !== 'none') {
864
897
  el.attr('fill', barbColor);
@@ -938,7 +971,8 @@ const drawDryAdiabats = ({
938
971
  xScale,
939
972
  yScale,
940
973
  svg,
941
- dp
974
+ dp,
975
+ diagramType
942
976
  }) => {
943
977
  for (let Tbase = -40; Tbase < 240; Tbase += 10) {
944
978
  let Pnew = staticPressureArray[0];
@@ -950,13 +984,15 @@ const drawDryAdiabats = ({
950
984
  Pnew += DPDT[0];
951
985
  Px.push([xScale(Tnew), yScale(pressure)]);
952
986
  }
953
- // Skew/shear transformation for skew-t
954
- for (const PxElement of Px) {
955
- 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
+ }
956
992
  }
957
993
  const lineGenerator = d3.line();
958
994
  const daPathString = lineGenerator(Px);
959
- svg.append('path').attr('d', daPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'green').style('opacity', 0.3).attr('clip-path', 'url(#clip-skewTd3)');
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)`);
960
996
  }
961
997
  };
962
998
  const tempPresDryAiabat = (pressure, pres0, temp0) => temp0 * (pressure / pres0) ** (2.0 / 7.0);
@@ -981,7 +1017,7 @@ const drawDryAdiabatsBijvoet = ({
981
1017
  const lineGenerator = d3.line();
982
1018
  const daPathString = lineGenerator(Px);
983
1019
  const strID = `${Tbase}-dryadiabat`;
984
- svg.append('path').attr('id', strID).attr('d', daPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'green').style('opacity', 0.3).attr('clip-path', 'url(#clip-bijvoetd3)');
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)');
985
1021
  }
986
1022
  };
987
1023
 
@@ -1008,7 +1044,8 @@ const drawIsohumes = ({
1008
1044
  height,
1009
1045
  xScale,
1010
1046
  yScale,
1011
- svg
1047
+ svg,
1048
+ diagramType
1012
1049
  }) => {
1013
1050
  for (const isohumeVal of isohumeValues) {
1014
1051
  const Px = [];
@@ -1018,14 +1055,16 @@ const drawIsohumes = ({
1018
1055
  const temp = 237.3 * logthing / (17.269 - logthing);
1019
1056
  Px.push([xScale(temp), yScale(pressureVal)]);
1020
1057
  }
1021
- // Skew/shear transformation for skew-t
1022
- for (const PxElement of Px) {
1023
- 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
+ }
1024
1063
  }
1025
1064
  const lineGenerator = d3.line();
1026
1065
  const pathStringQ = lineGenerator(Px);
1027
1066
  const strID = `${isohumeVal}isohume`;
1028
- 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)`);
1029
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');
1030
1069
  }
1031
1070
  };
@@ -1054,7 +1093,7 @@ const drawIsohumesBijvoet = ({
1054
1093
  const lineGenerator = d3.line();
1055
1094
  const pathStringQ = lineGenerator(Px);
1056
1095
  const strID = `${isohumeVal}isohume`;
1057
- svg.append('path').attr('id', strID).attr('d', pathStringQ).style('fill', 'none').style('stroke-width', 1).style('stroke-dasharray', '15, 8').style('stroke', '#5267A9').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-bijvoetd3)');
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)');
1058
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');
1059
1098
  }
1060
1099
  };
@@ -1081,7 +1120,8 @@ const drawIsotherms = ({
1081
1120
  height,
1082
1121
  xScale,
1083
1122
  yScale,
1084
- svg
1123
+ svg,
1124
+ diagramType
1085
1125
  }) => {
1086
1126
  // lines of constant temperature (10 degree interval from -80...+40C)
1087
1127
  for (let temp = -80; temp <= 40; temp += 10) {
@@ -1089,12 +1129,11 @@ const drawIsotherms = ({
1089
1129
  for (const pressure of staticPressureArray) {
1090
1130
  const Ppx = yScale(pressure);
1091
1131
  const Tpx = xScale(temp);
1092
- const skewedX = Tpx + (height - Ppx);
1093
- points.push([skewedX, Ppx]);
1132
+ points.push([diagramType === 'SKEWT' ? Tpx + (height - Ppx) : Tpx, Ppx]);
1094
1133
  }
1095
1134
  const lineGenerator = d3.line();
1096
1135
  const pathString = lineGenerator(points);
1097
- svg.append('path').attr('d', pathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'orange').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-skewTd3)');
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)`);
1098
1137
  }
1099
1138
  };
1100
1139
  const drawIsothermsBijvoet = ({
@@ -1115,8 +1154,8 @@ const drawIsothermsBijvoet = ({
1115
1154
  }
1116
1155
  const lineGenerator = d3.line();
1117
1156
  const pathString = lineGenerator(points);
1118
- const strID = `${temp}-isotherm-bijvoet`;
1119
- svg.append('path').attr('id', strID).attr('d', pathString).style('fill', 'none').style('stroke-width', temp % 10 === 0 ? 2 : 1).style('stroke', temp === 0 ? '#D27D2D' : 'orange').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-bijvoetd3)');
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)');
1120
1159
  // only show value every 10 degrees and if isotherm reaches bottom of plot
1121
1160
  if (temp % 10 === 0 && points[0] && points[0][1] === height) {
1122
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');
@@ -1145,7 +1184,7 @@ const CP = 1.03e3;
1145
1184
  const L_CONST = 2.5e6;
1146
1185
  const RD = 287.0;
1147
1186
  const RV = 461.0;
1148
- const moistAdiabaticLapserate = (minTemp, pressure, temperature, dp) => {
1187
+ const moistAdiabaticLapserate = (pressure, temperature, dp) => {
1149
1188
  const kelvin = temperature + KELVIN;
1150
1189
  const lsbc = L_CONST / RV * (1.0 / KELVIN - 1.0 / kelvin);
1151
1190
  const rw = 6.11 * Math.exp(lsbc) * (0.622 / pressure);
@@ -1167,7 +1206,8 @@ const drawMoistAdiabats = ({
1167
1206
  xScale,
1168
1207
  yScale,
1169
1208
  svg,
1170
- dp
1209
+ dp,
1210
+ diagramType
1171
1211
  }) => {
1172
1212
  /* We need the moist adiabat to cross the isotherm at 1000hPa
1173
1213
  Therefore we start the calculations from 1000hPa and work upwards (towards 0)
@@ -1184,19 +1224,21 @@ const drawMoistAdiabats = ({
1184
1224
  // Then calculate the rest of the moist adiabat for pressures <1000hPa
1185
1225
  for (const pressure of staticPressureArray) {
1186
1226
  if (pressure < 1000) {
1187
- const DPDT = moistAdiabaticLapserate(-80, Pnew, Tnew, dp);
1227
+ const DPDT = moistAdiabaticLapserate(Pnew, Tnew, dp);
1188
1228
  Tnew += DPDT[1];
1189
1229
  Pnew += DPDT[0];
1190
1230
  Px.push([xScale(Tnew), yScale(pressure)]);
1191
1231
  }
1192
1232
  }
1193
- for (const PxElement of Px) {
1194
- 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
+ }
1195
1237
  }
1196
1238
  const lineGenerator = d3.line();
1197
1239
  const maPathString = lineGenerator(Px);
1198
1240
  // Draw this first section of the line
1199
- 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)`);
1200
1242
  // Now work backwards from 1000hPa to calculate most adiabat until the bottom of the plot
1201
1243
  const PxBottom = [];
1202
1244
  PxBottom.push([Tpx1000, Ppx1000]);
@@ -1204,17 +1246,19 @@ const drawMoistAdiabats = ({
1204
1246
  let TnewBottom = Tbase;
1205
1247
  const startIndex = staticPressureArray.findIndex(value => value === 1000);
1206
1248
  for (let i = startIndex - 1; i >= 0; i -= 1) {
1207
- const DPDT = moistAdiabaticLapserate(-80, PnewBottom, TnewBottom, 5);
1249
+ const DPDT = moistAdiabaticLapserate(PnewBottom, TnewBottom, 5);
1208
1250
  TnewBottom += DPDT[1];
1209
1251
  PnewBottom += DPDT[0];
1210
1252
  PxBottom.push([xScale(TnewBottom), yScale(staticPressureArray[i])]);
1211
1253
  }
1212
- // Skew/shear transformation for skew-t
1213
- for (const PxElement of PxBottom) {
1214
- 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
+ }
1215
1259
  }
1216
1260
  const maPathStringBottom = lineGenerator(PxBottom);
1217
- svg.append('path').attr('d', maPathStringBottom).style('fill', 'none').style('stroke-width', 1).style('stroke', 'purple').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-skewTd3)');
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)`);
1218
1262
  }
1219
1263
  };
1220
1264
  const drawMoistAdiabatsBijvoet = ({
@@ -1234,7 +1278,7 @@ const drawMoistAdiabatsBijvoet = ({
1234
1278
  const lineGenerator = d3.line();
1235
1279
  const isoPathString = lineGenerator(Px);
1236
1280
  const strID = `${Tbase}-moistadiabat`;
1237
- svg.append('path').attr('id', strID).attr('d', isoPathString).style('fill', 'none').style('stroke-width', 1).style('stroke', 'purple').style('stroke-opacity', 0.5).attr('clip-path', 'url(#clip-bijvoetd3)');
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)');
1238
1282
  }
1239
1283
  };
1240
1284
 
@@ -1307,6 +1351,7 @@ const drawStaticLines = ({
1307
1351
  });
1308
1352
  } else {
1309
1353
  drawIsotherms({
1354
+ diagramType,
1310
1355
  staticPressureArray,
1311
1356
  height,
1312
1357
  xScale,
@@ -1314,6 +1359,7 @@ const drawStaticLines = ({
1314
1359
  svg
1315
1360
  });
1316
1361
  drawMoistAdiabats({
1362
+ diagramType,
1317
1363
  staticPressureArray,
1318
1364
  minTemperature,
1319
1365
  maxTemperature,
@@ -1327,6 +1373,7 @@ const drawStaticLines = ({
1327
1373
  dp
1328
1374
  });
1329
1375
  drawDryAdiabats({
1376
+ diagramType,
1330
1377
  staticPressureArray,
1331
1378
  height,
1332
1379
  xScale,
@@ -1335,6 +1382,7 @@ const drawStaticLines = ({
1335
1382
  dp
1336
1383
  });
1337
1384
  drawIsohumes({
1385
+ diagramType,
1338
1386
  staticPressureArray,
1339
1387
  height,
1340
1388
  xScale,
@@ -1389,7 +1437,7 @@ const Soundings = ({
1389
1437
  width: svgDimensions?.width || 812,
1390
1438
  height: svgDimensions?.height || 802,
1391
1439
  margin: {
1392
- top: svgDimensions?.margin?.top || 12,
1440
+ top: svgDimensions?.margin?.top || 4,
1393
1441
  right: svgDimensions?.margin?.right || 20,
1394
1442
  bottom: svgDimensions?.margin?.bottom || 25,
1395
1443
  left: svgDimensions?.margin?.left || 20
@@ -1518,7 +1566,7 @@ const Soundings = ({
1518
1566
  svg.on('mousemove', null);
1519
1567
  };
1520
1568
  // eslint-disable-next-line react-hooks/exhaustive-deps
1521
- }, [showTooltip, data]);
1569
+ }, [showTooltip, data, diagramType]);
1522
1570
  useEffect(() => {
1523
1571
  const handleMouseLeave = () => {
1524
1572
  if (pressureRef.current) {
@@ -1559,7 +1607,7 @@ const Soundings = ({
1559
1607
  windRef.current.style.display = 'none';
1560
1608
  }
1561
1609
  }, [data]);
1562
- return jsxs(Card, {
1610
+ return jsxs(Box, {
1563
1611
  ref: canvasRef,
1564
1612
  sx: {
1565
1613
  position: 'relative',
@@ -1622,20 +1670,22 @@ const getCollectionLocations = (collectionId, baseUrl) => fetch(`${baseUrl}/coll
1622
1670
  const getCollectionInstances = (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/instances`, {
1623
1671
  headers: getHeaders()
1624
1672
  }).then(handleResponse);
1625
- const getEdrCollectionForLocation = (collectionId, baseUrl, instanceId, locationId, parameters, datetime) => {
1673
+ const getEdrCollectionForLocation = (collectionId, baseUrl, instanceId, locationId, parameters, datetime, verticalRange) => {
1626
1674
  const url = new URL(`${baseUrl}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/locations/${locationId}`);
1627
1675
  url.searchParams.append('parameter-name', parameters.join(','));
1628
1676
  url.searchParams.append('datetime', datetime);
1677
+ url.searchParams.append('z', verticalRange);
1629
1678
  url.searchParams.append('f', 'CoverageJSON');
1630
1679
  return fetch(url.toString(), {
1631
1680
  headers: getHeaders()
1632
1681
  }).then(handleResponse);
1633
1682
  };
1634
- const getEdrCollectionForPosition = (collectionId, baseUrl, instanceId, coordinates, parameters, datetime) => {
1683
+ const getEdrCollectionForPosition = (collectionId, baseUrl, instanceId, coordinates, parameters, datetime, verticalRange) => {
1635
1684
  const url = new URL(`${baseUrl}/collections/${collectionId}${instanceId ? `/instances/${instanceId}` : ''}/position`);
1636
1685
  url.searchParams.append('coords', `POINT(${coordinates[0]} ${coordinates[1]})`);
1637
1686
  url.searchParams.append('parameter-name', parameters.join(','));
1638
1687
  url.searchParams.append('datetime', datetime);
1688
+ url.searchParams.append('z', verticalRange);
1639
1689
  url.searchParams.append('f', 'CoverageJSON');
1640
1690
  return fetch(url.toString(), {
1641
1691
  headers: getHeaders()
@@ -1845,27 +1895,29 @@ const getTimestepsFromInstance = instance => {
1845
1895
  if (!timeExtentValues) {
1846
1896
  return [];
1847
1897
  }
1848
- try {
1849
- // parse the time extent, if needed
1850
- if (timeExtentValues.length === 1) {
1851
- const timeExtent = timeExtentValues[0];
1852
- // duration with repeats
1853
- if (timeExtent.match(/^R\d+\/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\/\S+$/)) {
1854
- return parseDurationWithRepeats(timeExtent);
1898
+ const allParsedTimesteps = timeExtentValues.reduce((parsedSteps, currentValue) => {
1899
+ try {
1900
+ // Try to parse EDR time extent value in different possible formats
1901
+ // R<repeats>/<start>/P<period>
1902
+ if (currentValue.match(/^R\d+\/.*\/P.*$/)) {
1903
+ return [...parsedSteps, ...parseDurationWithRepeats(currentValue)];
1855
1904
  }
1856
- // format without repeats
1857
- const parseObject = new ParseISOTimeRangeDuration(timeExtent);
1858
- const timeValues = range(parseObject.getTimeSteps()).map(timestep => parseObject.getDateAtTimeStep(timestep).toISOString());
1859
- return timeValues;
1905
+ // <start>/<end>[/P<period>]
1906
+ if (currentValue.match(/.*\/.*P?.*/)) {
1907
+ const parseObject = new ParseISOTimeRangeDuration(currentValue);
1908
+ const timeValues = range(parseObject.getTimeSteps()).map(timestep => parseObject.getDateAtTimeStep(timestep).toISOString());
1909
+ return [...parsedSteps, ...timeValues];
1910
+ }
1911
+ // individual datetime string
1912
+ const isoFormattedValue = new Date(currentValue).toISOString();
1913
+ return [...parsedSteps, isoFormattedValue];
1914
+ } catch (error) {
1915
+ console.warn(error);
1916
+ // skip unsupported format
1917
+ return [...parsedSteps];
1860
1918
  }
1861
- // If more than one value, assume time extent is given as individual timesteps.
1862
- // Pass through toISOString to ensure consistent timestamp format.
1863
- const isoFormattedValues = timeExtentValues.map(value => new Date(value).toISOString());
1864
- return isoFormattedValues;
1865
- } catch (error) {
1866
- console.warn(error);
1867
- }
1868
- return [];
1919
+ }, []);
1920
+ return allParsedTimesteps;
1869
1921
  };
1870
1922
 
1871
1923
  /* *
@@ -1895,19 +1947,19 @@ const sortSoundingFeatures = data => {
1895
1947
  return featureB.properties.pressure - featureA.properties.pressure;
1896
1948
  });
1897
1949
  };
1898
- const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings) => {
1950
+ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, verticalRange) => {
1899
1951
  const parameters = getParameterMappings(parameterMappings);
1900
1952
  if (typeof location === 'string') {
1901
1953
  // LOCATION call
1902
1954
  return {
1903
- queryKey: ['edr-soundings-for-location', collectionId, instanceId || '', location, parameters.join(), datetime],
1955
+ queryKey: ['edr-soundings-for-location', collectionId, instanceId || '', location, parameters.join(), datetime, verticalRange],
1904
1956
  queryFn: async () => {
1905
- // No collection or location selected, don't do EDR call but return empty array
1906
- if (collectionId === undefined || location === '') {
1957
+ // No collection, location or time selected, don't do EDR call but return empty array
1958
+ if (collectionId === undefined || location === '' || datetime === '') {
1907
1959
  return [];
1908
1960
  }
1909
1961
  try {
1910
- const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime);
1962
+ const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
1911
1963
  const parsedData = parseCoverageCollection(rawData, parameterMappings);
1912
1964
  return sortSoundingFeatures(parsedData);
1913
1965
  } catch (err) {
@@ -1918,14 +1970,14 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
1918
1970
  }
1919
1971
  // POSITION call
1920
1972
  return {
1921
- queryKey: ['edr-soundings-for-position', collectionId, location.join(), parameters.join(), datetime],
1973
+ queryKey: ['edr-soundings-for-position', collectionId, location.join(), parameters.join(), datetime, verticalRange],
1922
1974
  queryFn: async () => {
1923
- // No collection or position selected, don't do EDR call but return empty array
1924
- if (collectionId === undefined || location === undefined) {
1975
+ // No collection, position or time selected, don't do EDR call but return empty array
1976
+ if (collectionId === undefined || location === undefined || datetime === '') {
1925
1977
  return [];
1926
1978
  }
1927
1979
  try {
1928
- const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime);
1980
+ const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
1929
1981
  const parsedData = parseCoverageCollection(rawData, parameterMappings);
1930
1982
  return sortSoundingFeatures(parsedData);
1931
1983
  } catch (err) {
@@ -1950,23 +2002,35 @@ const getParameterMappings = parameterMappings => {
1950
2002
  return Object.keys(parameterMappings).map(key => parameterMappings[key]);
1951
2003
  };
1952
2004
  const useMultipleSources = (sources, locations, datetime) => {
1953
- const queries = useMemo(() => sources.map(({
1954
- collectionId,
1955
- url,
1956
- instanceId,
1957
- parameterMappings
1958
- }) => {
1959
- const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', datetime || '', parameterMappings || defaultParameterMappings);
1960
- return queryParams;
1961
- }), [datetime, locations, sources]);
2005
+ const {
2006
+ data: timesteps,
2007
+ error: timestepsError,
2008
+ isPending: timestepsPending
2009
+ } = useTimesteps(sources);
2010
+ const queries = useMemo(() => {
2011
+ return sources.map(({
2012
+ collectionId,
2013
+ url,
2014
+ instanceId,
2015
+ parameterMappings,
2016
+ verticalRange
2017
+ }, index) => {
2018
+ // If acceptance time logic is needed, it could come here.
2019
+ const dateToUse = datetime &&
2020
+ // Only use selected time if exact match if found
2021
+ timesteps[index].includes(new Date(datetime).toISOString()) ? datetime : '';
2022
+ const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings, verticalRange || '');
2023
+ return queryParams;
2024
+ });
2025
+ }, [datetime, locations, sources, timesteps]);
1962
2026
  return useQueries({
1963
2027
  queries,
1964
2028
  combine: queryResults => {
1965
2029
  const data = queryResults.map(result => {
1966
2030
  return result.data || [];
1967
2031
  });
1968
- const isPending = queryResults.some(result => result.isPending);
1969
- const error = queryResults.find(result => result?.error)?.error;
2032
+ const isPending = timestepsPending || queryResults.some(result => result.isPending);
2033
+ const error = timestepsError || queryResults.find(result => result?.error)?.error;
1970
2034
  return {
1971
2035
  data,
1972
2036
  isPending,
@@ -1994,8 +2058,18 @@ const useTimesteps = sourceSelections => {
1994
2058
  const results = useQueries({
1995
2059
  queries,
1996
2060
  combine: queryResults => {
1997
- const timestepArrays = queryResults.map(result => result.data).filter(steps => !!steps);
1998
- return timestepArrays;
2061
+ const data = queryResults.map(result => result.data || []);
2062
+ // The underlying query is generic, so we rewrap the error to be more specific
2063
+ const foundError = queryResults.find(result => result?.error)?.error;
2064
+ const error = foundError ? new Error('Error fetching timesteps by collection', {
2065
+ cause: foundError
2066
+ }) : null;
2067
+ const isPending = queryResults.some(result => result.isPending);
2068
+ return {
2069
+ data,
2070
+ error,
2071
+ isPending
2072
+ };
1999
2073
  }
2000
2074
  });
2001
2075
  return results;
@@ -2298,6 +2372,7 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
2298
2372
  collectionId: key,
2299
2373
  url: value.url,
2300
2374
  instanceId: value.instanceId,
2375
+ verticalRange: value.verticalRange,
2301
2376
  parameterMappings: value.parameterMappings
2302
2377
  });
2303
2378
  }
@@ -2312,6 +2387,16 @@ const getSelectedSourcesIds = createSelector(getSelectedSources, activeSources =
2312
2387
  return activeSources.map(source => source.collectionId);
2313
2388
  }, selectorMemoizationOptions);
2314
2389
 
2390
+ const roundToClosestTimestep = (timeToRound, availableSteps) => {
2391
+ if (availableSteps.length > 0 && !availableSteps.includes(timeToRound)) {
2392
+ const selectionValue = dateUtils.unix(new Date(timeToRound));
2393
+ const closestTimestep = minBy(availableSteps, timeValue =>
2394
+ // distance to selected time
2395
+ Math.abs(selectionValue - dateUtils.unix(new Date(timeValue))));
2396
+ return closestTimestep || availableSteps[0];
2397
+ }
2398
+ return timeToRound;
2399
+ };
2315
2400
  const SoundingsTimeSliderConnect = () => {
2316
2401
  const selectedTime = useSelector(getSelectedTime);
2317
2402
  const sourceSelections = useSelector(getSelectedSources);
@@ -2321,13 +2406,22 @@ const SoundingsTimeSliderConnect = () => {
2321
2406
  selectedTime
2322
2407
  }));
2323
2408
  }, [dispatch]);
2324
- const timesteps = useTimesteps(sourceSelections);
2409
+ const {
2410
+ data: timesteps
2411
+ } = useTimesteps(sourceSelections);
2325
2412
  const mergedTimesteps = Array.from(new Set(timesteps.flat())).sort();
2326
2413
  // TODO: next line should work in Storybook but does not: https://gitlab.com/opengeoweb/geoweb-assets/-/issues/4025
2327
2414
  // const mergedTimesteps = [...new Set(timestepArrays.flat())].sort();
2415
+ // Ensure that the selected time is always one of the available timesteps
2416
+ const validSelectedTime = roundToClosestTimestep(selectedTime, mergedTimesteps);
2417
+ useEffect(() => {
2418
+ if (selectedTime !== validSelectedTime) {
2419
+ updateSelectedTime(validSelectedTime);
2420
+ }
2421
+ }, [selectedTime, updateSelectedTime, validSelectedTime]);
2328
2422
  return jsx(SoundingsTimeSlider, {
2329
2423
  timesteps: mergedTimesteps,
2330
- selectedTime: selectedTime,
2424
+ selectedTime: validSelectedTime,
2331
2425
  updateSelectedTime: updateSelectedTime
2332
2426
  });
2333
2427
  };
@@ -2416,9 +2510,84 @@ const MenuBarConnect = ({
2416
2510
  });
2417
2511
  };
2418
2512
 
2513
+ const soundingDiagrams = ['SKEWT', 'BIJVOET', 'EMAGRAM'];
2514
+
2515
+ const DiagramTypeButton = ({
2516
+ diagramType,
2517
+ setDiagramType
2518
+ }) => {
2519
+ const {
2520
+ t
2521
+ } = useSoundingsTranslation();
2522
+ const [anchorEl, setAnchorEl] = React.useState(null);
2523
+ const isOpen = Boolean(anchorEl);
2524
+ const handleClick = event => {
2525
+ setAnchorEl(anchorEl ? null : event.currentTarget);
2526
+ };
2527
+ return jsxs(CustomToggleButton, {
2528
+ variant: "tool",
2529
+ selected: isOpen,
2530
+ onClick: handleClick,
2531
+ "data-testid": "soundings-diagramtype-options-toolbutton",
2532
+ disableRipple: true,
2533
+ size: "small",
2534
+ sx: {
2535
+ height: '24px !important',
2536
+ width: '75px'
2537
+ },
2538
+ role: "button",
2539
+ children: [getDiagramName(t, diagramType), jsxs(Menu, {
2540
+ autoFocus: false,
2541
+ slotProps: {
2542
+ backdrop: {
2543
+ style: {
2544
+ opacity: 0,
2545
+ transition: 'none'
2546
+ }
2547
+ }
2548
+ },
2549
+ open: isOpen,
2550
+ anchorEl: anchorEl,
2551
+ anchorOrigin: {
2552
+ vertical: 21,
2553
+ horizontal: 6
2554
+ },
2555
+ sx: {
2556
+ '& .MuiMenu-list': {
2557
+ padding: 0
2558
+ },
2559
+ '& .MuiMenuItem-root': {
2560
+ fontSize: 16
2561
+ }
2562
+ },
2563
+ children: [jsx(ListSubheader, {
2564
+ sx: {
2565
+ paddingTop: '8px'
2566
+ },
2567
+ children: jsx(Typography, {
2568
+ sx: {
2569
+ letterSpacing: '0.4px',
2570
+ fontSize: 12
2571
+ },
2572
+ children: t('options')
2573
+ })
2574
+ }), soundingDiagrams.map(id => jsx(MenuItem, {
2575
+ onClick: () => {
2576
+ setDiagramType(id);
2577
+ },
2578
+ selected: diagramType === id,
2579
+ children: getDiagramName(t, id)
2580
+ }, id))]
2581
+ })]
2582
+ });
2583
+ };
2584
+
2585
+ const size = 600;
2419
2586
  const SoundingsConnect = ({
2420
- viewId
2587
+ viewId,
2588
+ diagramType = 'SKEWT'
2421
2589
  }) => {
2590
+ const [chosenDiagramType, setChosenDiagramType] = React.useState(diagramType);
2422
2591
  const selectedTime = useSelector(getSelectedTime);
2423
2592
  const sourceSelections = useSelector(getSelectedSources);
2424
2593
  const locationSelections = useSelector(getLocationSelections);
@@ -2448,23 +2617,58 @@ const SoundingsConnect = ({
2448
2617
  children: jsx(MenuBarConnect, {
2449
2618
  viewId: viewId
2450
2619
  })
2451
- }), jsx(Soundings, {
2452
- data: data,
2453
- svgDimensions: {
2454
- width: 600,
2455
- height: 600,
2456
- margin: {
2457
- bottom: 0,
2458
- left: 0,
2459
- right: 0,
2460
- top: 0
2461
- }
2462
- }
2620
+ }), jsxs(Card, {
2621
+ children: [jsx(Box, {
2622
+ sx: {
2623
+ display: 'flex',
2624
+ justifyContent: 'flex-end',
2625
+ paddingTop: '2px',
2626
+ paddingRight: '2px',
2627
+ width: size
2628
+ },
2629
+ children: jsx(DiagramTypeButton, {
2630
+ diagramType: chosenDiagramType,
2631
+ setDiagramType: setChosenDiagramType
2632
+ })
2633
+ }), jsx(Soundings, {
2634
+ data: data,
2635
+ svgDimensions: {
2636
+ width: size,
2637
+ height: size,
2638
+ margin: {
2639
+ bottom: 0,
2640
+ left: 0,
2641
+ right: 0,
2642
+ top: 0
2643
+ }
2644
+ },
2645
+ diagramType: chosenDiagramType
2646
+ })]
2463
2647
  }), jsx(SoundingsTimeSliderConnect, {})]
2464
2648
  })]
2465
2649
  });
2466
2650
  };
2467
2651
 
2652
+ const extractVerticalRange = (collection, selectedInstanceId) => {
2653
+ const instance = collection.instances.find(instance => instance.id === selectedInstanceId);
2654
+ const interval = instance?.extent?.vertical?.interval;
2655
+ if (interval) {
2656
+ // Fallback for FMI server while it sends a string array instead of string[][] as specified in documentation
2657
+ if (typeof interval[0] === 'string') {
2658
+ const verticalDims = interval.toSorted((a, b) => a.localeCompare(b, undefined, {
2659
+ numeric: true
2660
+ })) ?? [];
2661
+ return verticalDims.join('/');
2662
+ }
2663
+ const firstEl = interval[0][0];
2664
+ const lastEl = interval[interval.length - 1][1];
2665
+ const verticalDims = [firstEl, lastEl].toSorted((a, b) => a.localeCompare(b, undefined, {
2666
+ numeric: true
2667
+ })) ?? [];
2668
+ return verticalDims.join('/');
2669
+ }
2670
+ return '';
2671
+ };
2468
2672
  const SoundingsSourceMenu = ({
2469
2673
  soundingsCollections,
2470
2674
  isOpen = false,
@@ -2527,11 +2731,15 @@ const SoundingsSourceMenu = ({
2527
2731
  },
2528
2732
  children: jsx(Checkbox, {
2529
2733
  checked: !!isChecked,
2530
- onChange: event => updateCollection(collection.id, {
2531
- url,
2532
- isChecked: event.target.checked,
2533
- instanceId: selectedInstanceId
2534
- }),
2734
+ onChange: event => {
2735
+ const verticalRange = selectedInstanceId !== undefined ? extractVerticalRange(collection, selectedInstanceId) : '';
2736
+ updateCollection(collection.id, {
2737
+ url,
2738
+ isChecked: event.target.checked,
2739
+ instanceId: selectedInstanceId,
2740
+ verticalRange
2741
+ });
2742
+ },
2535
2743
  disableRipple: true
2536
2744
  })
2537
2745
  }), jsxs(StyledSelect, {
@@ -2541,10 +2749,15 @@ const SoundingsSourceMenu = ({
2541
2749
  },
2542
2750
  skipLocalStyling: false,
2543
2751
  value: selectedInstanceId || t('noInstancesAvailable'),
2544
- onChange: event => updateCollection(collection.id, {
2545
- url,
2546
- instanceId: event.target.value
2547
- }),
2752
+ onChange: event => {
2753
+ const instanceId = event.target.value;
2754
+ const verticalRange = extractVerticalRange(collection, instanceId);
2755
+ updateCollection(collection.id, {
2756
+ url,
2757
+ instanceId,
2758
+ verticalRange
2759
+ });
2760
+ },
2548
2761
  renderValue: value => jsx(Box, {
2549
2762
  sx: {
2550
2763
  textOverflow: 'ellipsis',
@@ -2820,7 +3033,8 @@ const soundingsComponentType = 'Soundings';
2820
3033
  const soundingsLookUp = payload => {
2821
3034
  if (payload.componentType === soundingsComponentType) {
2822
3035
  return jsx(SoundingsConnect, {
2823
- viewId: payload.id
3036
+ viewId: payload.id,
3037
+ diagramType: payload.diagramType ? payload.diagramType : 'SKEWT'
2824
3038
  });
2825
3039
  }
2826
3040
  return null;
@@ -2854,4 +3068,4 @@ const createMockStore = mockState => configureStore({
2854
3068
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(storeMiddlewares)
2855
3069
  });
2856
3070
 
2857
- export { LocationsMenuConnect, SOUNDINGS_NAMESPACE, SoundingsConnect, SoundingsSourceDialogConnect, createMockStore, initSoundingsI18n, soundingsComponentType, soundingsLookUp, soundingsReducersMap, soundingsTranslations, translateKeyOutsideComponents, useSoundingsTranslation };
3071
+ export { LocationsMenuConnect, SOUNDINGS_NAMESPACE, SoundingsConnect, SoundingsSourceDialogConnect, createMockStore, initSoundingsI18n, soundingDiagrams, soundingsComponentType, soundingsLookUp, soundingsReducersMap, soundingsTranslations, translateKeyOutsideComponents, useSoundingsTranslation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeoweb/soundings",
3
- "version": "12.6.0",
3
+ "version": "12.8.0",
4
4
  "description": "GeoWeb soundings library for the opengeoweb project",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -8,25 +8,26 @@
8
8
  "url": "git@gitlab.com:opengeoweb/opengeoweb.git"
9
9
  },
10
10
  "dependencies": {
11
- "@opengeoweb/api": "12.6.0",
11
+ "@opengeoweb/api": "12.8.0",
12
12
  "@tanstack/react-query": "^5.69.2",
13
13
  "react": "^18.3.1",
14
14
  "d3": "^7.9.0",
15
15
  "react-i18next": "^15.1.1",
16
16
  "i18next": "^25.0.1",
17
- "@opengeoweb/shared": "12.6.0",
17
+ "@opengeoweb/shared": "12.8.0",
18
18
  "@mui/material": "^7.0.1",
19
- "@opengeoweb/store": "12.6.0",
19
+ "@opengeoweb/store": "12.8.0",
20
20
  "react-redux": "^9.2.0",
21
- "@opengeoweb/theme": "12.6.0",
21
+ "@opengeoweb/theme": "12.8.0",
22
22
  "@reduxjs/toolkit": "^2.6.1",
23
- "@opengeoweb/timeslider": "12.6.0",
23
+ "@opengeoweb/timeslider": "12.8.0",
24
24
  "react-window": "^1.8.10",
25
- "@opengeoweb/webmap": "12.6.0",
26
- "lodash": "^4.17.21"
25
+ "@opengeoweb/webmap": "12.8.0",
26
+ "lodash": "^4.17.21",
27
+ "@types/geojson": "^7946.0.14"
27
28
  },
28
29
  "module": "./index.esm.js",
29
30
  "type": "module",
30
31
  "main": "./index.esm.js",
31
- "types": "./index.esm.d.ts"
32
+ "types": "./index.d.ts"
32
33
  }
@@ -2,5 +2,5 @@ import { CoverageCollection } from '@opengeoweb/shared';
2
2
  import { LocationsResponseFromBE, SoundingsInstances } from './types';
3
3
  export declare const getCollectionLocations: (collectionId: string, baseUrl: string) => Promise<LocationsResponseFromBE>;
4
4
  export declare const getCollectionInstances: (collectionId: string, baseUrl: string) => Promise<SoundingsInstances>;
5
- export declare const getEdrCollectionForLocation: (collectionId: string, baseUrl: string, instanceId: string | undefined, locationId: string, parameters: string[], datetime: string) => Promise<CoverageCollection>;
6
- export declare const getEdrCollectionForPosition: (collectionId: string, baseUrl: string, instanceId: string | undefined, coordinates: [number, number], parameters: string[], datetime: string) => Promise<CoverageCollection>;
5
+ export declare const getEdrCollectionForLocation: (collectionId: string, baseUrl: string, instanceId: string | undefined, locationId: string, parameters: string[], datetime: string, verticalRange: string) => Promise<CoverageCollection>;
6
+ export declare const getEdrCollectionForPosition: (collectionId: string, baseUrl: string, instanceId: string | undefined, coordinates: [number, number], parameters: string[], datetime: string, verticalRange: string) => Promise<CoverageCollection>;
@@ -3,6 +3,7 @@ import { SoundingFeature } from './types';
3
3
  import { SourceType } from '../../store/types';
4
4
  import { SoundingsCollectionsWithInstances } from '../../components/Soundings/types';
5
5
  import { ServiceWithLocations } from '../../components/MenuBar/LocationsMenu';
6
+ export declare const sortSoundingFeatures: (data: SoundingFeature[]) => SoundingFeature[];
6
7
  type Coordinates = [number, number];
7
8
  export declare const defaultParameterMappings: {
8
9
  temperature: string;
@@ -22,7 +23,11 @@ export declare const useMultipleSources: (sources: SourceType[], locations: Reco
22
23
  isPending: boolean;
23
24
  error: Error | null | undefined;
24
25
  };
25
- export declare const useTimesteps: (sourceSelections: SourceType[]) => string[][];
26
+ export declare const useTimesteps: (sourceSelections: SourceType[]) => {
27
+ data: string[][];
28
+ error: Error | null;
29
+ isPending: boolean;
30
+ };
26
31
  export declare const useGetCollectionsInstances: (collections?: SoundingsCollection[], isDialogOpen?: boolean) => {
27
32
  data: SoundingsCollectionsWithInstances[] | undefined;
28
33
  isPending: boolean;
@@ -31,4 +31,5 @@ export type SoundingsInstance = EDRInstance;
31
31
  export interface SoundingsInstances {
32
32
  instances: SoundingsInstance[];
33
33
  }
34
- export type SoundingDiagramType = 'SKEWT' | 'BIJVOET';
34
+ export declare const soundingDiagrams: readonly ["SKEWT", "BIJVOET", "EMAGRAM"];
35
+ export type SoundingDiagramType = (typeof soundingDiagrams)[number];
@@ -9,4 +9,5 @@ interface SoundingsStoreWrapperProps {
9
9
  store?: Store;
10
10
  }
11
11
  export declare const SoundingsStoreProvider: React.FC<SoundingsStoreWrapperProps>;
12
+ export declare const SoundingsStoryStoreProvider: React.FC<SoundingsStoreWrapperProps>;
12
13
  export {};
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { SoundingDiagramType } from '../../api/edr-api/types';
3
+ interface DiagramTypeButtonProps {
4
+ diagramType: SoundingDiagramType;
5
+ setDiagramType: (diagramType: SoundingDiagramType) => void;
6
+ }
7
+ export declare const DiagramTypeButton: ({ diagramType, setDiagramType, }: DiagramTypeButtonProps) => React.ReactElement;
8
+ export {};
@@ -3,7 +3,9 @@ import { Soundings } from './Soundings';
3
3
  declare const meta: Meta<typeof Soundings>;
4
4
  export default meta;
5
5
  type Story = StoryObj<typeof Soundings>;
6
- export declare const SoundingsExample: Story;
7
- export declare const SoundingsExampleDark: Story;
6
+ export declare const SoundingsSkewTExample: Story;
7
+ export declare const SoundingsSkewTExampleDark: Story;
8
+ export declare const SoundingsEmagramExample: Story;
9
+ export declare const SoundingsEmagramExampleDark: Story;
8
10
  export declare const SoundingsBijvoetExample: Story;
9
11
  export declare const SoundingsBijvoetExampleDark: Story;
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
+ import { SoundingDiagramType } from '../../api/edr-api/types';
2
3
  interface SoundingsConnectProps {
3
4
  viewId: string;
5
+ diagramType?: SoundingDiagramType;
4
6
  }
5
- export declare const SoundingsConnect: ({ viewId, }: SoundingsConnectProps) => React.ReactElement;
7
+ export declare const SoundingsConnect: ({ viewId, diagramType, }: SoundingsConnectProps) => React.ReactElement;
6
8
  export {};
@@ -1,8 +1,13 @@
1
+ import { StoryObj } from '@storybook/react/*';
2
+ import { SoundingsConnect } from '..';
1
3
  declare const _default: {
2
4
  title: string;
3
5
  tags: string[];
4
6
  };
5
7
  export default _default;
8
+ type Story = StoryObj<typeof SoundingsConnect>;
9
+ export declare const SoundingsConnectStory: Story;
10
+ export declare const SoundingsConnectStoryDark: Story;
6
11
  export declare const SoundingsConnectLoading: {
7
12
  (): React.ReactElement;
8
13
  parameters: {
@@ -1,5 +1,6 @@
1
1
  /// <reference types="react" />
2
2
  import * as d3 from 'd3';
3
+ import { i18n, TFunction } from 'i18next';
3
4
  import { SoundingDiagramType } from '../../api/edr-api/types';
4
5
  import { ScalesType } from './types';
5
6
  export declare const SOUNDING_COLORS: string[];
@@ -38,6 +39,7 @@ interface PlotSounding {
38
39
  svg: d3.Selection<SVGElement, unknown, HTMLElement, undefined>;
39
40
  diagramType: SoundingDiagramType;
40
41
  }
42
+ export declare const getDiagramName: (t: TFunction<'soundings', i18n>, diagramType: SoundingDiagramType) => string;
41
43
  export declare const initializeSvg: ({ svgRef, width, height, margin, diagramType, }: InitializeSvg) => d3.Selection<SVGGElement, unknown, null, undefined>;
42
44
  export declare const createScales: (diagramType: SoundingDiagramType, width: number, height: number, minPressure: number, maxPressure: number, minTemperature: number, maxTemperature: number) => [ScalesType, ScalesType];
43
45
  export declare const drawAxes: ({ svg, xScale, yScale, height, diagramType, }: DrawAxis) => void;
@@ -1,5 +1,6 @@
1
1
  import * as d3 from 'd3';
2
2
  import { ScalesType } from '../Soundings/types';
3
+ import { SoundingDiagramType } from '../../api/edr-api/types';
3
4
  interface DrawDryAdiabatsProps {
4
5
  staticPressureArray: number[];
5
6
  height: number;
@@ -7,8 +8,9 @@ interface DrawDryAdiabatsProps {
7
8
  yScale: ScalesType;
8
9
  dp: number;
9
10
  svg: d3.Selection<SVGElement, unknown, HTMLElement, unknown>;
11
+ diagramType: SoundingDiagramType;
10
12
  }
11
- export declare const drawDryAdiabats: ({ staticPressureArray, height, xScale, yScale, svg, dp, }: DrawDryAdiabatsProps) => void;
13
+ export declare const drawDryAdiabats: ({ staticPressureArray, height, xScale, yScale, svg, dp, diagramType, }: DrawDryAdiabatsProps) => void;
12
14
  interface DrawDryAdiabatsBijvoetProps {
13
15
  staticPressureArray: number[];
14
16
  xScale: ScalesType;
@@ -1,13 +1,15 @@
1
1
  import * as d3 from 'd3';
2
2
  import { ScalesType } from '../Soundings/types';
3
+ import { SoundingDiagramType } from '../../api/edr-api/types';
3
4
  interface DrawIsohumesProps {
4
5
  staticPressureArray: number[];
5
6
  height: number;
6
7
  xScale: ScalesType;
7
8
  yScale: ScalesType;
8
9
  svg: d3.Selection<SVGElement, unknown, HTMLElement, unknown>;
10
+ diagramType: SoundingDiagramType;
9
11
  }
10
- export declare const drawIsohumes: ({ staticPressureArray, height, xScale, yScale, svg, }: DrawIsohumesProps) => void;
12
+ export declare const drawIsohumes: ({ staticPressureArray, height, xScale, yScale, svg, diagramType, }: DrawIsohumesProps) => void;
11
13
  interface DrawIsohumesBijvoetProps {
12
14
  staticPressureArray: number[];
13
15
  height: number;
@@ -1,13 +1,15 @@
1
1
  import * as d3 from 'd3';
2
2
  import { ScalesType } from '../Soundings/types';
3
+ import { SoundingDiagramType } from '../../api/edr-api/types';
3
4
  interface DrawIsothermsProps {
4
5
  staticPressureArray: number[];
5
6
  height: number;
6
7
  xScale: ScalesType;
7
8
  yScale: ScalesType;
8
9
  svg: d3.Selection<SVGElement, unknown, HTMLElement, unknown>;
10
+ diagramType: SoundingDiagramType;
9
11
  }
10
- export declare const drawIsotherms: ({ staticPressureArray, height, xScale, yScale, svg, }: DrawIsothermsProps) => void;
12
+ export declare const drawIsotherms: ({ staticPressureArray, height, xScale, yScale, svg, diagramType, }: DrawIsothermsProps) => void;
11
13
  interface DrawIsothermsBijvoetProps {
12
14
  staticPressureArray: number[];
13
15
  xScale: ScalesType;
@@ -1,5 +1,6 @@
1
1
  import * as d3 from 'd3';
2
2
  import { ScalesType } from '../Soundings/types';
3
+ import { SoundingDiagramType } from '../../api/edr-api/types';
3
4
  interface DrawMoistAdiabatsProps {
4
5
  staticPressureArray: number[];
5
6
  minTemperature: number;
@@ -12,8 +13,9 @@ interface DrawMoistAdiabatsProps {
12
13
  xScale: ScalesType;
13
14
  yScale: ScalesType;
14
15
  svg: d3.Selection<SVGElement, unknown, HTMLElement, unknown>;
16
+ diagramType: SoundingDiagramType;
15
17
  }
16
- export declare const drawMoistAdiabats: ({ staticPressureArray, minTemperature, maxTemperature, minPressure, maxPressure, width, height, xScale, yScale, svg, dp, }: DrawMoistAdiabatsProps) => void;
18
+ export declare const drawMoistAdiabats: ({ staticPressureArray, minTemperature, maxTemperature, minPressure, maxPressure, width, height, xScale, yScale, svg, dp, diagramType, }: DrawMoistAdiabatsProps) => void;
17
19
  interface DrawMoistAdiabatsBijvoetProps {
18
20
  minPressure: number;
19
21
  maxPressure: number;
@@ -2,6 +2,7 @@ export interface SourceType {
2
2
  collectionId: string;
3
3
  url: string;
4
4
  instanceId?: string;
5
+ verticalRange?: string;
5
6
  parameterMappings?: Record<string, string>;
6
7
  }
7
8
  export interface UpdateSourcePayload {
@@ -12,6 +13,7 @@ export interface SourceSelection {
12
13
  url: string;
13
14
  instanceId?: string;
14
15
  isChecked?: boolean;
16
+ verticalRange?: string;
15
17
  parameterMappings?: Record<string, string>;
16
18
  }
17
19
  export interface UpdateLocationPayload {
@@ -1,8 +1,10 @@
1
+ import { SoundingDiagramType } from '../api/edr-api/types';
1
2
  export declare const soundingsComponentType = "Soundings";
2
3
  export interface SoundingsLookUpPayload {
3
4
  componentType: typeof soundingsComponentType;
4
5
  id: string;
5
6
  title?: string;
7
+ diagramType?: SoundingDiagramType;
6
8
  initialProps: Record<string, unknown>;
7
9
  }
8
10
  export declare const soundingsLookUp: (payload: SoundingsLookUpPayload) => React.JSX.Element | null;
File without changes