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