@opengeoweb/soundings 15.2.0 → 16.0.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 +5 -1
- package/index.esm.js +579 -124
- package/package.json +8 -8
- package/src/lib/api/edr-api/hooks.d.ts +1 -1
- package/src/lib/api/edr-api/utils.d.ts +3 -1
- package/src/lib/components/Hodograph/Hodograph.d.ts +6 -0
- package/src/lib/components/Hodograph/Hodograph.spec.d.ts +1 -0
- package/src/lib/components/Hodograph/Hodograph.stories.d.ts +7 -0
- package/src/lib/components/Hodograph/HodographCollection.d.ts +6 -0
- package/src/lib/components/Hodograph/HodographCollection.spec.d.ts +1 -0
- package/src/lib/components/Hodograph/HodographLegend.d.ts +2 -0
- package/src/lib/components/Hodograph/drawUtils.d.ts +53 -0
- package/src/lib/components/Hodograph/drawUtils.spec.d.ts +1 -0
- package/src/lib/components/Soundings/SoundingsConnect.stories.d.ts +1 -0
- package/src/lib/components/Soundings/timeMismatchUtils.d.ts +4 -0
- package/src/lib/components/SoundingsTimeSlider/SoundingsTimeSliderConnect.d.ts +3 -1
- package/src/lib/store/types.d.ts +1 -0
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ There, you can specify the required details for each source under the `soundings
|
|
|
15
15
|
"id": "harmonie_scandinavia_hybrid",
|
|
16
16
|
"url": "https://opendata.fmi.fi/edr",
|
|
17
17
|
"description": "MEPS/vertical coordinate model level",
|
|
18
|
+
"windSpeedUnit": "ms",
|
|
18
19
|
"parameterMappings": {
|
|
19
20
|
"temperature": "temperature",
|
|
20
21
|
"windDirection": "winddirection",
|
|
@@ -28,6 +29,7 @@ There, you can specify the required details for each source under the `soundings
|
|
|
28
29
|
"id": "harmonie_scandinavia_pressure",
|
|
29
30
|
"url": "https://opendata.fmi.fi/edr",
|
|
30
31
|
"description": "MEPS/vertical coordinate pressure",
|
|
32
|
+
"windSpeedUnit": "ms",
|
|
31
33
|
"parameterMappings": {
|
|
32
34
|
"temperature": "temperature",
|
|
33
35
|
"windDirection": "winddirection",
|
|
@@ -39,7 +41,9 @@ There, you can specify the required details for each source under the `soundings
|
|
|
39
41
|
]
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
Each source should have a name
|
|
44
|
+
Each source should have a `name`, `id`, `source url`, `description`, `windSpeedUnit` and include parameter mappings telling the soundings API which EDR parameters to assign to which physical property.
|
|
45
|
+
|
|
46
|
+
The `windSpeedUnit` is required to ensure correct derivation of the windbarbs and hodograph. This should contain either 'ms' or 'kts' depending on the unit served by the EDR service
|
|
43
47
|
|
|
44
48
|
The following mappings are needed:
|
|
45
49
|
|
package/index.esm.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
2
|
-
import { Menu, ListSubheader, Typography, MenuItem, Box,
|
|
3
|
-
import { range,
|
|
2
|
+
import { Menu, ListSubheader, Typography, MenuItem, Box, Stack, Card, IconButton, Tooltip, LinearProgress, List, ListItem, ListItemIcon, Checkbox, ListItemText, Grid, ListItemButton } from '@mui/material';
|
|
3
|
+
import { range, isEqual } from 'lodash';
|
|
4
4
|
import React, { useRef, useEffect, useMemo, useState, Fragment as Fragment$1 } from 'react';
|
|
5
5
|
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
|
|
6
6
|
import { safelyInitI18nForTestsAndStories, sharedTranslations, SHARED_NAMESPACE, CustomToggleButton, dateUtils, SwitchButton, CustomSlider, AlertBanner, ToolContainerDraggable, StyledSelect, SearchField, CustomAccordion } from '@opengeoweb/shared';
|
|
7
|
-
import { selectorMemoizationOptions, uiSelectors, uiTypes, uiActions, genericSelectors,
|
|
8
|
-
import {
|
|
7
|
+
import { selectorMemoizationOptions, uiSelectors, uiTypes, uiActions, genericActions, genericSelectors, useSyncgroupTimeState, mapSelectors, syncGroupsActions, mapActions, useSetupDialog, storeReducerMap, storeMiddlewares } from '@opengeoweb/store';
|
|
8
|
+
import { ArrowDropDownDown, DragHandle, Layers, Sources, Close, Visibility, VisibilityOff } from '@opengeoweb/theme';
|
|
9
|
+
import { ParseISOTimeRangeDuration, parseISO8601IntervalToDateInterval, handleDateUtilsISOString, webmapUtils } from '@opengeoweb/webmap';
|
|
9
10
|
import { MapFeatureClass } from '@opengeoweb/webmap-react';
|
|
10
11
|
import { ReactSortable } from 'react-sortablejs';
|
|
11
|
-
import { ArrowDropDownDown, DragHandle, Layers, Sources, Visibility, VisibilityOff } from '@opengeoweb/theme';
|
|
12
12
|
import * as d3 from 'd3';
|
|
13
13
|
import i18n from 'i18next';
|
|
14
14
|
export { default as i18n } from 'i18next';
|
|
@@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
16
16
|
import { timesliderTranslations, TIME_SLIDER_NAMESPACE, BackwardForwardStepButton } from '@opengeoweb/time-slider';
|
|
17
17
|
import { useResizable } from 'react-resizable-layout';
|
|
18
18
|
import { useQueries } from '@tanstack/react-query';
|
|
19
|
-
import { getHeaders, handleResponse } from '@opengeoweb/api';
|
|
19
|
+
import { withJsonFormat, getHeaders, handleResponse } from '@opengeoweb/api';
|
|
20
20
|
import { createSlice, createAction, createSelector, combineReducers, configureStore } from '@reduxjs/toolkit';
|
|
21
21
|
import { List as List$1 } from 'react-window';
|
|
22
22
|
|
|
@@ -32,6 +32,7 @@ var en = {
|
|
|
32
32
|
"diagram-BIJVOET": "Bijvoet",
|
|
33
33
|
"diagram-EMAGRAM": "Emagram",
|
|
34
34
|
"coming-soon": "Coming soon",
|
|
35
|
+
"soundings-time-mismatch-title": "Selected time in the sounding view does not match map view time.",
|
|
35
36
|
"soundings-toggle-location-markers-label": "Markers",
|
|
36
37
|
"soundings-toggle-location-markers-tooltip": "Show/hide location markers"
|
|
37
38
|
};
|
|
@@ -47,8 +48,9 @@ var no = {
|
|
|
47
48
|
"diagram-BIJVOET": "Bijvoet",
|
|
48
49
|
"diagram-EMAGRAM": "Emagram",
|
|
49
50
|
"coming-soon": "Kommer snart",
|
|
50
|
-
"soundings-
|
|
51
|
-
"soundings-toggle-location-markers-
|
|
51
|
+
"soundings-time-mismatch-title": "Selected time in the sounding view does not match map view time.",
|
|
52
|
+
"soundings-toggle-location-markers-label": "Lokasjoner",
|
|
53
|
+
"soundings-toggle-location-markers-tooltip": "Vis/skjul lokasjoner"
|
|
52
54
|
};
|
|
53
55
|
var nl = {
|
|
54
56
|
sources: "Bronnen",
|
|
@@ -62,6 +64,7 @@ var nl = {
|
|
|
62
64
|
"diagram-BIJVOET": "Bijvoet",
|
|
63
65
|
"diagram-EMAGRAM": "Emagram",
|
|
64
66
|
"coming-soon": "Binnenkort beschikbaar",
|
|
67
|
+
"soundings-time-mismatch-title": "Selected time in the sounding view does not match map view time.",
|
|
65
68
|
"soundings-toggle-location-markers-label": "Kaart locaties",
|
|
66
69
|
"soundings-toggle-location-markers-tooltip": "Toon/Verberg locatie punten"
|
|
67
70
|
};
|
|
@@ -77,6 +80,7 @@ var fi = {
|
|
|
77
80
|
"diagram-BIJVOET": "Bijvoet",
|
|
78
81
|
"diagram-EMAGRAM": "Emagram",
|
|
79
82
|
"coming-soon": "Tulossa pian",
|
|
83
|
+
"soundings-time-mismatch-title": "Selected time in the sounding view does not match map view time.",
|
|
80
84
|
"soundings-toggle-location-markers-label": "Merkit",
|
|
81
85
|
"soundings-toggle-location-markers-tooltip": "Näytä/piilota kohdemerkit"
|
|
82
86
|
};
|
|
@@ -326,7 +330,7 @@ const getDiagramName = (t, diagramType) => {
|
|
|
326
330
|
}
|
|
327
331
|
};
|
|
328
332
|
// vfx code
|
|
329
|
-
const initializeSvg = ({
|
|
333
|
+
const initializeSvg$1 = ({
|
|
330
334
|
svgRef,
|
|
331
335
|
width,
|
|
332
336
|
height,
|
|
@@ -345,7 +349,7 @@ const initializeSvg = ({
|
|
|
345
349
|
svg.append('rect').attr('height', height).attr('width', width).attr('fill', '#ffffff00').attr('opacity', 1).attr('id', `${d3GraphId}-${diagramType}d3`).attr('clip-path', `url(#clip-${d3GraphId}-${diagramType}d3)`);
|
|
346
350
|
return svg;
|
|
347
351
|
};
|
|
348
|
-
const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
|
|
352
|
+
const createScales$1 = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
|
|
349
353
|
if (diagramType === 'BIJVOET') {
|
|
350
354
|
// Note, for Bijvoet, the x-axis is NOT temperature but theta_es
|
|
351
355
|
// Both axis aren't linear nor logarithmic, hence we need an interpolation function to determine (x,y) based on (theta_es,p)
|
|
@@ -358,7 +362,7 @@ const createScales = (diagramType, width, height, minPressure, maxPressure, minT
|
|
|
358
362
|
const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
|
|
359
363
|
return [xScale, yScale];
|
|
360
364
|
};
|
|
361
|
-
const drawAxes = ({
|
|
365
|
+
const drawAxes$1 = ({
|
|
362
366
|
svg,
|
|
363
367
|
xScale,
|
|
364
368
|
yScale,
|
|
@@ -697,7 +701,7 @@ const drawSoundingsTooltip = ({
|
|
|
697
701
|
// Wind
|
|
698
702
|
windRefd3?.style('display', 'block').style('left', `${width + margin.left + margin.right}px`).style('top', `${pressureY}px`).html(() => {
|
|
699
703
|
const windSpeedStrings = interpolatedData.map((dataSet, index) => {
|
|
700
|
-
return getParamHtml(dataSet.hoveredWindSpeed, index, '
|
|
704
|
+
return getParamHtml(dataSet.hoveredWindSpeed, index, 'kts', index === interpolatedData.length - 1, dataSet.color, true);
|
|
701
705
|
});
|
|
702
706
|
const windDirStrings = interpolatedData.map((dataSet, index) => {
|
|
703
707
|
return getParamHtml(dataSet.hoveredWindDirection, index, '°', index === interpolatedData.length - 1, dataSet.color, index === interpolatedData.length - 1);
|
|
@@ -1231,7 +1235,7 @@ const Soundings = ({
|
|
|
1231
1235
|
if (!svgRef.current) {
|
|
1232
1236
|
return;
|
|
1233
1237
|
}
|
|
1234
|
-
const svg = initializeSvg({
|
|
1238
|
+
const svg = initializeSvg$1({
|
|
1235
1239
|
svgRef,
|
|
1236
1240
|
width,
|
|
1237
1241
|
height,
|
|
@@ -1245,9 +1249,9 @@ const Soundings = ({
|
|
|
1245
1249
|
widthRef.current = width;
|
|
1246
1250
|
const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
|
|
1247
1251
|
const maxT = maxTemperature;
|
|
1248
|
-
const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1252
|
+
const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1249
1253
|
yScaleRef.current = yScale;
|
|
1250
|
-
drawAxes({
|
|
1254
|
+
drawAxes$1({
|
|
1251
1255
|
svg,
|
|
1252
1256
|
xScale,
|
|
1253
1257
|
yScale,
|
|
@@ -1334,7 +1338,7 @@ const Soundings = ({
|
|
|
1334
1338
|
const dewpointRefd3 = d3.select(dewpointRef.current);
|
|
1335
1339
|
const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
|
|
1336
1340
|
const maxT = maxTemperature;
|
|
1337
|
-
const [, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1341
|
+
const [, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1338
1342
|
drawSoundingsTooltip({
|
|
1339
1343
|
event: synthetic,
|
|
1340
1344
|
yScale,
|
|
@@ -1738,7 +1742,6 @@ const windSpeedRanges = [{
|
|
|
1738
1742
|
* Copyright 2025 - Finnish Meteorological Institute (FMI)
|
|
1739
1743
|
* Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
|
|
1740
1744
|
* */
|
|
1741
|
-
const metersPerSecondToKnots = metersPerSecond => metersPerSecond * 1.94384;
|
|
1742
1745
|
const extendIsoBar = (path, horizontalBarbMargin) => {
|
|
1743
1746
|
const match = /^M([\d.-]+),([\d.-]+)\s*L([\d.-]+),([\d.-]+)/.exec(path);
|
|
1744
1747
|
if (match) {
|
|
@@ -1846,11 +1849,10 @@ const removeItemsExponential = array => {
|
|
|
1846
1849
|
return array.filter((_, index) => indicesToKeep.includes(index));
|
|
1847
1850
|
};
|
|
1848
1851
|
const getWindSymbol = value => {
|
|
1849
|
-
const valueKts = metersPerSecondToKnots(value);
|
|
1850
1852
|
const matchedRange = windSpeedRanges.find(({
|
|
1851
1853
|
min,
|
|
1852
1854
|
max
|
|
1853
|
-
}) =>
|
|
1855
|
+
}) => value >= min && value < max);
|
|
1854
1856
|
// Out of range
|
|
1855
1857
|
if (!matchedRange) {
|
|
1856
1858
|
return null;
|
|
@@ -1929,7 +1931,7 @@ const WindBarbs = ({
|
|
|
1929
1931
|
}
|
|
1930
1932
|
const root = d3.select(svgRef.current);
|
|
1931
1933
|
root.selectAll('*').remove();
|
|
1932
|
-
const svg = initializeSvg({
|
|
1934
|
+
const svg = initializeSvg$1({
|
|
1933
1935
|
svgRef,
|
|
1934
1936
|
width,
|
|
1935
1937
|
height,
|
|
@@ -1947,7 +1949,7 @@ const WindBarbs = ({
|
|
|
1947
1949
|
widthRef.current = width;
|
|
1948
1950
|
const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
|
|
1949
1951
|
const maxT = maxTemperature;
|
|
1950
|
-
const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1952
|
+
const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1951
1953
|
yScaleRef.current = yScale;
|
|
1952
1954
|
drawIsobars({
|
|
1953
1955
|
diagramType,
|
|
@@ -2040,6 +2042,257 @@ const WindBarbs = ({
|
|
|
2040
2042
|
});
|
|
2041
2043
|
};
|
|
2042
2044
|
|
|
2045
|
+
/* *
|
|
2046
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
2047
|
+
* you may not use this file except in compliance with the License.
|
|
2048
|
+
* You may obtain a copy of the License at
|
|
2049
|
+
*
|
|
2050
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
2051
|
+
*
|
|
2052
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
2053
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
2054
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
2055
|
+
* See the License for the specific language governing permissions and
|
|
2056
|
+
* limitations under the License.
|
|
2057
|
+
*
|
|
2058
|
+
* Copyright 2026 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
|
|
2059
|
+
* Copyright 2026 - Finnish Meteorological Institute (FMI)
|
|
2060
|
+
* Copyright 2026 - The Norwegian Meteorological Institute (MET Norway)
|
|
2061
|
+
* */
|
|
2062
|
+
const initializeSvg = ({
|
|
2063
|
+
svgRef,
|
|
2064
|
+
widthHeight
|
|
2065
|
+
}) => {
|
|
2066
|
+
const svg = d3.select(svgRef.current).attr('data-testid', 'hodograph').attr('width', '100%').attr('height', '100%').attr('viewBox', `0 0 ${widthHeight + 3} ${widthHeight + 3}`).attr('style', 'width: 100%; height: auto;');
|
|
2067
|
+
const gSel = svg.select('g.hodograph');
|
|
2068
|
+
if (gSel.empty()) {
|
|
2069
|
+
svg.append('g').attr('class', 'hodograph');
|
|
2070
|
+
}
|
|
2071
|
+
return svg;
|
|
2072
|
+
};
|
|
2073
|
+
const createScales = widthHeight => {
|
|
2074
|
+
const scaleRadius = d3.scaleLinear().domain([0, 100]).range([0, widthHeight / 2]);
|
|
2075
|
+
const scaleAngle = d3.scaleLinear().domain([0, 360]).range([0, 2 * Math.PI]);
|
|
2076
|
+
return [scaleRadius, scaleAngle];
|
|
2077
|
+
};
|
|
2078
|
+
const drawAxes = ({
|
|
2079
|
+
svg,
|
|
2080
|
+
scaleRadius,
|
|
2081
|
+
scaleAngle,
|
|
2082
|
+
widthHeight
|
|
2083
|
+
}) => {
|
|
2084
|
+
// Draw circles
|
|
2085
|
+
svg.select('g').selectAll('circle').data([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]).join(enter => enter.append('circle').attr('cx', widthHeight / 2).attr('cy', widthHeight / 2).attr('fill', 'none').attr('stroke', 'gray').style('opacity', 0.6).attr('stroke-dasharray', data => data === 50 || data === 100 ? 'none' : '5, 4').attr('r', data => scaleRadius(data)));
|
|
2086
|
+
// Draw windspeed scale
|
|
2087
|
+
const speedTicks = scaleRadius.ticks(9);
|
|
2088
|
+
svg.select('g').append('g').attr('type', 'windspeed').attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`) // origin transformed to center
|
|
2089
|
+
.call(d3.axisBottom(scaleRadius).tickValues(speedTicks).tickSizeInner(0).tickSizeOuter(0))
|
|
2090
|
+
// Draw unit label
|
|
2091
|
+
.append('text').attr('fill', 'currentColor').attr('x', scaleRadius(90)).attr('y', '0').attr('font-size', '11px').attr('dy', '2.2em').attr('dx', '0.8em').attr('text-anchor', 'left').text('kts');
|
|
2092
|
+
};
|
|
2093
|
+
// Use this function to filter the pressure and parameter array so no NaN or null values are included
|
|
2094
|
+
const filterDomainRangeForHodograph = (pressureDomain, parameterDomain) => {
|
|
2095
|
+
const filteredPressureDomain = pressureDomain.filter((_value, index) => parameterDomain[index] !== null && !isNaN(parameterDomain[index][0]) && parameterDomain[index][0] !== null && !isNaN(parameterDomain[index][1]) && parameterDomain[index][1] !== null);
|
|
2096
|
+
const filteredParameterDomain = parameterDomain.filter(value => !isNaN(value[0]) && value[0] !== null && !isNaN(value[1]) && value[1] !== null);
|
|
2097
|
+
// If first pressure value is smallest, reverse arrays to ensure we draw starting from surface
|
|
2098
|
+
if (filteredPressureDomain[0] < filteredPressureDomain[filteredPressureDomain.length - 1]) {
|
|
2099
|
+
return [filteredPressureDomain.reverse(), filteredParameterDomain.reverse()];
|
|
2100
|
+
}
|
|
2101
|
+
return [filteredPressureDomain, filteredParameterDomain];
|
|
2102
|
+
};
|
|
2103
|
+
const T0_SEA = 288.15; // Standard temperature in Kelvin (15 degrees Celsius)
|
|
2104
|
+
const P0_SEA = 101325; // Reference pressure (Pa)
|
|
2105
|
+
const R_GAS = 8.314462618; // Universal gas constant (J/mol K)
|
|
2106
|
+
const L_TEMP = 0.0065; // Temperature gradient (K/m) at sea level
|
|
2107
|
+
const G_ACC = 9.80665; // The gravitational acceleration in units of geopotential height (m/s2)
|
|
2108
|
+
const M_AIR = 0.0289647; // Mean molar mass of air at sea level (kg/mol)
|
|
2109
|
+
const exponent = R_GAS * L_TEMP / (G_ACC * M_AIR); // ≈ 0.1903
|
|
2110
|
+
// This function returns the altitude above MSL using the barometric formula in m
|
|
2111
|
+
// Pressure passed in is in hPa, which is converted to Pa (pressure * 100)
|
|
2112
|
+
const altitudeBarometricFromPressure = pressure => {
|
|
2113
|
+
const altitude = T0_SEA / L_TEMP * ((P0_SEA / (pressure * 100)) ** exponent - 1);
|
|
2114
|
+
return altitude;
|
|
2115
|
+
};
|
|
2116
|
+
const generateSegmentsBasedOnLevel = (pressure, windValues) => {
|
|
2117
|
+
const segments = [[], [], [], [], []];
|
|
2118
|
+
pressure.forEach((pressureVal, index) => {
|
|
2119
|
+
// Ensure we ignore the last value
|
|
2120
|
+
if (pressure[index + 1]) {
|
|
2121
|
+
const heightInMeters = altitudeBarometricFromPressure(pressureVal);
|
|
2122
|
+
// index 0, red line
|
|
2123
|
+
if (heightInMeters < 1000) {
|
|
2124
|
+
segments[0].push(windValues[index]);
|
|
2125
|
+
segments[0].push(windValues[index + 1]);
|
|
2126
|
+
}
|
|
2127
|
+
// index 1, green line
|
|
2128
|
+
if (heightInMeters >= 1000 && heightInMeters < 3000) {
|
|
2129
|
+
segments[1].push(windValues[index]);
|
|
2130
|
+
segments[1].push(windValues[index + 1]);
|
|
2131
|
+
}
|
|
2132
|
+
// index 2, yellow line
|
|
2133
|
+
if (heightInMeters >= 3000 && heightInMeters < 6000) {
|
|
2134
|
+
segments[2].push(windValues[index]);
|
|
2135
|
+
segments[2].push(windValues[index + 1]);
|
|
2136
|
+
}
|
|
2137
|
+
// index 3, purple line
|
|
2138
|
+
if (heightInMeters >= 6000 && heightInMeters < 9000) {
|
|
2139
|
+
segments[3].push(windValues[index]);
|
|
2140
|
+
segments[3].push(windValues[index + 1]);
|
|
2141
|
+
}
|
|
2142
|
+
// index 4, black line
|
|
2143
|
+
if (heightInMeters >= 9000 && heightInMeters <= 19000) {
|
|
2144
|
+
segments[4].push(windValues[index]);
|
|
2145
|
+
segments[4].push(windValues[index + 1]);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
});
|
|
2149
|
+
return segments;
|
|
2150
|
+
};
|
|
2151
|
+
const HODOGRAPH_COLORS = ['red', 'olivedrab', 'orange', 'darkviolet', 'black'];
|
|
2152
|
+
const getColorForSegment = index => {
|
|
2153
|
+
if (HODOGRAPH_COLORS[index]) {
|
|
2154
|
+
return HODOGRAPH_COLORS[index];
|
|
2155
|
+
}
|
|
2156
|
+
return 'black';
|
|
2157
|
+
};
|
|
2158
|
+
const plotHodograph = ({
|
|
2159
|
+
windValues,
|
|
2160
|
+
pressureValues,
|
|
2161
|
+
scaleAngle,
|
|
2162
|
+
scaleRadius,
|
|
2163
|
+
widthHeight,
|
|
2164
|
+
svg,
|
|
2165
|
+
index
|
|
2166
|
+
}) => {
|
|
2167
|
+
// Filter out any NaN or null values
|
|
2168
|
+
const [filteredPressureValues, filteredWindValues] = filterDomainRangeForHodograph(pressureValues, windValues);
|
|
2169
|
+
// Construct path segments to be able to give each segment it's own color
|
|
2170
|
+
const pathSegments = generateSegmentsBasedOnLevel(filteredPressureValues, filteredWindValues);
|
|
2171
|
+
// index 0 = wind direction, index 1 = wind speed
|
|
2172
|
+
const lineGenerator = d3.lineRadial().angle(dataPoint => scaleAngle(dataPoint[0])).radius(dataPoint => scaleRadius(dataPoint[1]));
|
|
2173
|
+
svg.select('g.hodograph').selectAll(`path.dataline-${index}`).data(pathSegments).enter().append('path').classed(`dataline-${index}`, true).attr('data-testid', `hodograph-dataline-${index}`).attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`).attr('fill', 'none').attr('stroke', (data, segmentIndex) => getColorForSegment(segmentIndex)).attr('stroke-width', 2).style('stroke-opacity', 1).attr('d', data => lineGenerator(data));
|
|
2174
|
+
// Adding a dot for the very first data point in the color used in the soundings graph
|
|
2175
|
+
const firstDataPoint = filteredWindValues[0];
|
|
2176
|
+
if (firstDataPoint) {
|
|
2177
|
+
const color = SOUNDING_COLORS[index] || SOUNDING_COLORS[0];
|
|
2178
|
+
svg.select('g.hodograph').append('g').attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`) // origin transformed to center
|
|
2179
|
+
.attr('fill', color).attr('stroke', 'none').attr('data-testid', `hodograph-datastart-${index}`).selectAll().data([firstDataPoint]).join('circle').attr('cx', dataPoint => {
|
|
2180
|
+
const angle = scaleAngle(dataPoint[0]);
|
|
2181
|
+
const radius = scaleRadius(dataPoint[1]);
|
|
2182
|
+
return radius * Math.sin(angle);
|
|
2183
|
+
}).attr('cy', dataPoint => {
|
|
2184
|
+
const angle = scaleAngle(dataPoint[0]);
|
|
2185
|
+
const radius = scaleRadius(dataPoint[1]);
|
|
2186
|
+
return radius * -1 * Math.cos(angle);
|
|
2187
|
+
}).attr('r', 3);
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
|
|
2191
|
+
const Hodograph = ({
|
|
2192
|
+
data
|
|
2193
|
+
}) => {
|
|
2194
|
+
const svgRef = useRef(null);
|
|
2195
|
+
const widthHeight = 400;
|
|
2196
|
+
useEffect(() => {
|
|
2197
|
+
if (!svgRef.current) {
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
// Define svg
|
|
2201
|
+
const svg = initializeSvg({
|
|
2202
|
+
svgRef,
|
|
2203
|
+
widthHeight
|
|
2204
|
+
});
|
|
2205
|
+
// Set scales for temperature and pressure
|
|
2206
|
+
const [scaleRadius, scaleAngle] = createScales(widthHeight);
|
|
2207
|
+
// Add axis'
|
|
2208
|
+
drawAxes({
|
|
2209
|
+
svg,
|
|
2210
|
+
scaleRadius,
|
|
2211
|
+
scaleAngle,
|
|
2212
|
+
widthHeight
|
|
2213
|
+
});
|
|
2214
|
+
data.forEach((features, index) => {
|
|
2215
|
+
const windValues = features?.map(feature => {
|
|
2216
|
+
// Transform windDirectionFrom to windDirectionTo
|
|
2217
|
+
const windDirectionTo = ((feature.properties.windDirection + 180) % 360 + 360) % 360;
|
|
2218
|
+
return [windDirectionTo, feature.properties.windSpeed];
|
|
2219
|
+
});
|
|
2220
|
+
const pressureValues = features.map(feature => feature.properties.pressure);
|
|
2221
|
+
// Draw Temperature
|
|
2222
|
+
plotHodograph({
|
|
2223
|
+
windValues,
|
|
2224
|
+
pressureValues,
|
|
2225
|
+
scaleAngle,
|
|
2226
|
+
scaleRadius,
|
|
2227
|
+
widthHeight,
|
|
2228
|
+
svg,
|
|
2229
|
+
index
|
|
2230
|
+
});
|
|
2231
|
+
});
|
|
2232
|
+
// Allow zooming and panning
|
|
2233
|
+
const zoomBehavior = d3.zoom().scaleExtent([1, 7]).translateExtent([[-0.2 * widthHeight, -0.2 * widthHeight], [1.2 * widthHeight, 1.2 * widthHeight]]).on('zoom', event => {
|
|
2234
|
+
svg.select('g.hodograph').attr('transform', event.transform);
|
|
2235
|
+
});
|
|
2236
|
+
svg.call(zoomBehavior);
|
|
2237
|
+
// eslint-disable-next-line consistent-return
|
|
2238
|
+
return () => {
|
|
2239
|
+
svg.on('zoom', null);
|
|
2240
|
+
svg.select('g.hodograph').selectAll('*').remove();
|
|
2241
|
+
};
|
|
2242
|
+
}, [data, widthHeight]);
|
|
2243
|
+
return jsx("svg", {
|
|
2244
|
+
height: "100%",
|
|
2245
|
+
ref: svgRef,
|
|
2246
|
+
width: "100%"
|
|
2247
|
+
});
|
|
2248
|
+
};
|
|
2249
|
+
|
|
2250
|
+
const getLegendItem = (color, text) => {
|
|
2251
|
+
return jsxs(Typography, {
|
|
2252
|
+
sx: {
|
|
2253
|
+
color: 'geowebColors.typographyAndIcons.text',
|
|
2254
|
+
fontSize: '9px'
|
|
2255
|
+
},
|
|
2256
|
+
variant: "caption",
|
|
2257
|
+
children: [jsx(Box, {
|
|
2258
|
+
sx: {
|
|
2259
|
+
width: '16px',
|
|
2260
|
+
height: '4px',
|
|
2261
|
+
backgroundColor: color,
|
|
2262
|
+
display: 'inline-block',
|
|
2263
|
+
marginRight: '4px',
|
|
2264
|
+
marginBottom: '2px'
|
|
2265
|
+
}
|
|
2266
|
+
}), text]
|
|
2267
|
+
});
|
|
2268
|
+
};
|
|
2269
|
+
const HodographLegend = () => {
|
|
2270
|
+
return jsxs(Stack, {
|
|
2271
|
+
direction: "column",
|
|
2272
|
+
justifyContent: "center",
|
|
2273
|
+
sx: {
|
|
2274
|
+
flexWrap: 'wrap',
|
|
2275
|
+
paddingLeft: 2,
|
|
2276
|
+
paddingTop: 0.5
|
|
2277
|
+
},
|
|
2278
|
+
useFlexGap: true,
|
|
2279
|
+
children: [getLegendItem(HODOGRAPH_COLORS[0], '0 - 1 km'), getLegendItem(HODOGRAPH_COLORS[1], '1 - 3 km'), getLegendItem(HODOGRAPH_COLORS[2], '3 - 6 km'), getLegendItem(HODOGRAPH_COLORS[3], '6 - 9 km'), getLegendItem(HODOGRAPH_COLORS[4], '9 - 19 km')]
|
|
2280
|
+
});
|
|
2281
|
+
};
|
|
2282
|
+
|
|
2283
|
+
const HodographCollection = ({
|
|
2284
|
+
data
|
|
2285
|
+
}) => {
|
|
2286
|
+
return jsxs(Box, {
|
|
2287
|
+
sx: {
|
|
2288
|
+
padding: '5px'
|
|
2289
|
+
},
|
|
2290
|
+
children: [jsx(Hodograph, {
|
|
2291
|
+
data: data
|
|
2292
|
+
}), jsx(HodographLegend, {})]
|
|
2293
|
+
});
|
|
2294
|
+
};
|
|
2295
|
+
|
|
2043
2296
|
const ACCORDION_MIN_WIDTH = 24;
|
|
2044
2297
|
const SoundingsAccordion = React.forwardRef(({
|
|
2045
2298
|
name,
|
|
@@ -2353,6 +2606,14 @@ const SortableAccordions = ({
|
|
|
2353
2606
|
}
|
|
2354
2607
|
})
|
|
2355
2608
|
});
|
|
2609
|
+
case 'hodograph':
|
|
2610
|
+
return jsx(SoundingsAccordion, {
|
|
2611
|
+
maxWidth: 500,
|
|
2612
|
+
name: "Hodograph",
|
|
2613
|
+
children: jsx(HodographCollection, {
|
|
2614
|
+
data: data
|
|
2615
|
+
})
|
|
2616
|
+
});
|
|
2356
2617
|
default:
|
|
2357
2618
|
return null;
|
|
2358
2619
|
}
|
|
@@ -2487,6 +2748,40 @@ const SoundingsLegend = ({
|
|
|
2487
2748
|
});
|
|
2488
2749
|
};
|
|
2489
2750
|
|
|
2751
|
+
/* *
|
|
2752
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
2753
|
+
* you may not use this file except in compliance with the License.
|
|
2754
|
+
* You may obtain a copy of the License at
|
|
2755
|
+
*
|
|
2756
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
2757
|
+
*
|
|
2758
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
2759
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
2760
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
2761
|
+
* See the License for the specific language governing permissions and
|
|
2762
|
+
* limitations under the License.
|
|
2763
|
+
*
|
|
2764
|
+
* Copyright 2026 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
|
|
2765
|
+
* Copyright 2026 - Finnish Meteorological Institute (FMI)
|
|
2766
|
+
* Copyright 2026 - The Norwegian Meteorological Institute (MET Norway)
|
|
2767
|
+
* */
|
|
2768
|
+
const getTimeMismatchInfo = (isTimeSynced, warningTimeInUse, sourceTimesteps) => {
|
|
2769
|
+
if (!isTimeSynced || !warningTimeInUse) {
|
|
2770
|
+
return undefined;
|
|
2771
|
+
}
|
|
2772
|
+
const requestedTimestamp = new Date(warningTimeInUse).getTime();
|
|
2773
|
+
const allSourceTimesteps = sourceTimesteps.flat().map(timestep => new Date(timestep).getTime()).filter(time => Number.isFinite(time)).sort((aTime, bTime) => aTime - bTime);
|
|
2774
|
+
if (allSourceTimesteps.length === 0) {
|
|
2775
|
+
return undefined;
|
|
2776
|
+
}
|
|
2777
|
+
const firstAvailableTime = allSourceTimesteps[0];
|
|
2778
|
+
const lastAvailableTime = allSourceTimesteps[allSourceTimesteps.length - 1];
|
|
2779
|
+
const isOutsideOverallSoundingsRange = requestedTimestamp < firstAvailableTime || requestedTimestamp > lastAvailableTime;
|
|
2780
|
+
return isOutsideOverallSoundingsRange ? {
|
|
2781
|
+
mismatch: true
|
|
2782
|
+
} : undefined;
|
|
2783
|
+
};
|
|
2784
|
+
|
|
2490
2785
|
/* *
|
|
2491
2786
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
2492
2787
|
* you may not use this file except in compliance with the License.
|
|
@@ -2504,13 +2799,13 @@ const SoundingsLegend = ({
|
|
|
2504
2799
|
* Copyright 2025 - Finnish Meteorological Institute (FMI)
|
|
2505
2800
|
* Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
|
|
2506
2801
|
* */
|
|
2507
|
-
const getCollectionLocations = async (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/locations
|
|
2802
|
+
const getCollectionLocations = async (collectionId, baseUrl) => fetch(withJsonFormat(`${baseUrl}/collections/${collectionId}/locations`), {
|
|
2508
2803
|
headers: getHeaders()
|
|
2509
2804
|
}).then(handleResponse);
|
|
2510
|
-
const getCollectionInstances = async (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}/instances
|
|
2805
|
+
const getCollectionInstances = async (collectionId, baseUrl) => fetch(withJsonFormat(`${baseUrl}/collections/${collectionId}/instances`), {
|
|
2511
2806
|
headers: getHeaders()
|
|
2512
2807
|
}).then(handleResponse);
|
|
2513
|
-
const getCollectionDetails = async (collectionId, baseUrl) => fetch(`${baseUrl}/collections/${collectionId}
|
|
2808
|
+
const getCollectionDetails = async (collectionId, baseUrl) => fetch(withJsonFormat(`${baseUrl}/collections/${collectionId}`), {
|
|
2514
2809
|
headers: getHeaders()
|
|
2515
2810
|
}).then(handleResponse);
|
|
2516
2811
|
const getEdrCollectionForLocation = async (collectionId, baseUrl, instanceId, locationId, parameters, datetime, verticalRange) => {
|
|
@@ -2558,6 +2853,7 @@ const aConstant = 17.67;
|
|
|
2558
2853
|
const bConstant = 243.5; // Constants for water, b in Celsius
|
|
2559
2854
|
const gConstant = 461.5; // Gas constant ratio between water vapour and air [J/(K*kg)]
|
|
2560
2855
|
const hConstant = 2.5e6; // Latent heat for water vaporization [J/kg]
|
|
2856
|
+
const ktsConstant = 1.94384;
|
|
2561
2857
|
// Formula to use when dewpoint is not available
|
|
2562
2858
|
// Known as Magnus Formula
|
|
2563
2859
|
// - Calculate vapor pressure
|
|
@@ -2582,8 +2878,34 @@ relHumidity) {
|
|
|
2582
2878
|
const dewpoint = tKelvin / (1 - tKelvin * Math.log(relHumidity * 0.01) * gConstant / hConstant);
|
|
2583
2879
|
return dewpoint;
|
|
2584
2880
|
}
|
|
2881
|
+
const getWindSpeedInKts = (windSpeed, windSpeedUnit) => {
|
|
2882
|
+
if (windSpeedUnit === 'kts') {
|
|
2883
|
+
return windSpeed;
|
|
2884
|
+
}
|
|
2885
|
+
// Formula to convert from m/s to kts
|
|
2886
|
+
return windSpeed * ktsConstant;
|
|
2887
|
+
};
|
|
2888
|
+
const getNearestTimestep = (requestedDatetime, availableTimesteps) => {
|
|
2889
|
+
if (!requestedDatetime || availableTimesteps.length === 0) {
|
|
2890
|
+
return '';
|
|
2891
|
+
}
|
|
2892
|
+
const requestedTime = new Date(requestedDatetime).getTime();
|
|
2893
|
+
let nearestTimestep = availableTimesteps[0];
|
|
2894
|
+
let nearestTimestepMs = new Date(nearestTimestep).getTime();
|
|
2895
|
+
let minDiff = Math.abs(nearestTimestepMs - requestedTime);
|
|
2896
|
+
for (const timestep of availableTimesteps) {
|
|
2897
|
+
const timestepMs = new Date(timestep).getTime();
|
|
2898
|
+
const diff = Math.abs(timestepMs - requestedTime);
|
|
2899
|
+
if (diff < minDiff || diff === minDiff && timestepMs < nearestTimestepMs) {
|
|
2900
|
+
minDiff = diff;
|
|
2901
|
+
nearestTimestep = timestep;
|
|
2902
|
+
nearestTimestepMs = timestepMs;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
return nearestTimestep;
|
|
2906
|
+
};
|
|
2585
2907
|
// Parsing and conversion function
|
|
2586
|
-
function parseCoverageCollection(response, parameterMappings) {
|
|
2908
|
+
function parseCoverageCollection(response, parameterMappings, windSpeedUnit) {
|
|
2587
2909
|
const features = [];
|
|
2588
2910
|
const pressureArray = [];
|
|
2589
2911
|
const tempArray = [];
|
|
@@ -2687,13 +3009,14 @@ function parseCoverageCollection(response, parameterMappings) {
|
|
|
2687
3009
|
dewpoint = dewpointFromRelativeHumidity(tempArray[index], relativeHumidity);
|
|
2688
3010
|
}
|
|
2689
3011
|
// Extract wind speed and direction - use fallback calculations if no speed and direction in parameter mappings
|
|
3012
|
+
// Convert to kts if retrieved in m/s
|
|
2690
3013
|
let windSpeed = NaN;
|
|
2691
3014
|
if (parameterMappings.windSpeed) {
|
|
2692
|
-
windSpeed = collectionData.windSpeedArray[index];
|
|
3015
|
+
windSpeed = getWindSpeedInKts(collectionData.windSpeedArray[index], windSpeedUnit);
|
|
2693
3016
|
} else if (parameterMappings.windU && parameterMappings.windV) {
|
|
2694
3017
|
const windU = collectionData.windUArray[index];
|
|
2695
3018
|
const windV = collectionData.windVArray[index];
|
|
2696
|
-
windSpeed = Math.sqrt(windU ** 2 + windV ** 2);
|
|
3019
|
+
windSpeed = getWindSpeedInKts(Math.sqrt(windU ** 2 + windV ** 2), windSpeedUnit);
|
|
2697
3020
|
}
|
|
2698
3021
|
let windDirection = NaN;
|
|
2699
3022
|
if (parameterMappings.windDirection) {
|
|
@@ -2802,7 +3125,7 @@ const sortSoundingFeatures = data => {
|
|
|
2802
3125
|
return featureB.properties.pressure - featureA.properties.pressure;
|
|
2803
3126
|
});
|
|
2804
3127
|
};
|
|
2805
|
-
const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, verticalRange) => {
|
|
3128
|
+
const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, windSpeedUnit, verticalRange) => {
|
|
2806
3129
|
const parameters = getParameterMappings(parameterMappings);
|
|
2807
3130
|
if (typeof location === 'string') {
|
|
2808
3131
|
// LOCATION call
|
|
@@ -2815,7 +3138,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
|
|
|
2815
3138
|
}
|
|
2816
3139
|
try {
|
|
2817
3140
|
const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
|
|
2818
|
-
const parsedData = parseCoverageCollection(rawData, parameterMappings);
|
|
3141
|
+
const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
|
|
2819
3142
|
return sortSoundingFeatures(parsedData);
|
|
2820
3143
|
} catch (_err) {
|
|
2821
3144
|
throw new Error('Error fetching EDR data collection by location');
|
|
@@ -2833,7 +3156,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
|
|
|
2833
3156
|
}
|
|
2834
3157
|
try {
|
|
2835
3158
|
const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
|
|
2836
|
-
const parsedData = parseCoverageCollection(rawData, parameterMappings);
|
|
3159
|
+
const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
|
|
2837
3160
|
return sortSoundingFeatures(parsedData);
|
|
2838
3161
|
} catch (_err) {
|
|
2839
3162
|
throw new Error('Error fetching EDR data collection by position');
|
|
@@ -2856,7 +3179,7 @@ const defaultParameterMappings = {
|
|
|
2856
3179
|
const getParameterMappings = parameterMappings => {
|
|
2857
3180
|
return Object.keys(parameterMappings).map(key => parameterMappings[key]);
|
|
2858
3181
|
};
|
|
2859
|
-
const useMultipleSources = (sources, locations, datetime) => {
|
|
3182
|
+
const useMultipleSources = (sources, locations, datetime, timeSelectionMode = 'nearestWithinRange') => {
|
|
2860
3183
|
const {
|
|
2861
3184
|
data: timesteps,
|
|
2862
3185
|
error: timestepsError,
|
|
@@ -2868,34 +3191,34 @@ const useMultipleSources = (sources, locations, datetime) => {
|
|
|
2868
3191
|
url,
|
|
2869
3192
|
instanceId,
|
|
2870
3193
|
parameterMappings,
|
|
3194
|
+
windSpeedUnit,
|
|
2871
3195
|
verticalRange
|
|
2872
3196
|
}, index) => {
|
|
2873
|
-
//
|
|
3197
|
+
// Per source: choose either exact match or nearest-in-range timestep.
|
|
2874
3198
|
let dateToUse = '';
|
|
2875
3199
|
if (datetime && timesteps[index] && timesteps[index].length > 0) {
|
|
2876
3200
|
const requestedTime = new Date(datetime).getTime();
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
3201
|
+
if (Number.isFinite(requestedTime)) {
|
|
3202
|
+
const exactMatch = timesteps[index].find(timestep => new Date(timestep).getTime() === requestedTime);
|
|
3203
|
+
if (timeSelectionMode === 'exact') {
|
|
3204
|
+
dateToUse = exactMatch || '';
|
|
3205
|
+
} else {
|
|
3206
|
+
const availableTimes = timesteps[index].map(timestep => new Date(timestep).getTime()).filter(time => Number.isFinite(time)).sort((aTime, bTime) => aTime - bTime);
|
|
3207
|
+
if (availableTimes.length > 0) {
|
|
3208
|
+
const firstAvailableTime = availableTimes[0];
|
|
3209
|
+
const lastAvailableTime = availableTimes[availableTimes.length - 1];
|
|
3210
|
+
const isWithinSourceRange = requestedTime >= firstAvailableTime && requestedTime <= lastAvailableTime;
|
|
3211
|
+
if (isWithinSourceRange) {
|
|
3212
|
+
dateToUse = getNearestTimestep(datetime, timesteps[index]);
|
|
3213
|
+
}
|
|
2890
3214
|
}
|
|
2891
3215
|
}
|
|
2892
|
-
dateToUse = nearestTimestep;
|
|
2893
3216
|
}
|
|
2894
3217
|
}
|
|
2895
|
-
const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings, verticalRange || '');
|
|
3218
|
+
const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings, windSpeedUnit || 'ms', verticalRange || '');
|
|
2896
3219
|
return queryParams;
|
|
2897
3220
|
});
|
|
2898
|
-
}, [datetime, locations, sources, timesteps]);
|
|
3221
|
+
}, [datetime, locations, sources, timeSelectionMode, timesteps]);
|
|
2899
3222
|
return useQueries({
|
|
2900
3223
|
queries,
|
|
2901
3224
|
combine: queryResults => {
|
|
@@ -2935,7 +3258,8 @@ const useTimesteps = sourceSelections => {
|
|
|
2935
3258
|
return getTimestepsFromInstanceOrCollection(instance);
|
|
2936
3259
|
}
|
|
2937
3260
|
return getTimestepsFromInstanceOrCollection(data);
|
|
2938
|
-
}
|
|
3261
|
+
},
|
|
3262
|
+
refetchInterval: 5 * 60 * 1000
|
|
2939
3263
|
})), [sourceSelections]);
|
|
2940
3264
|
const results = useQueries({
|
|
2941
3265
|
queries,
|
|
@@ -3138,7 +3462,7 @@ const initialSoundingsState = {
|
|
|
3138
3462
|
selectedTime: new Date().toISOString(),
|
|
3139
3463
|
diagramType: 'SKEWT',
|
|
3140
3464
|
soundingPanels: {
|
|
3141
|
-
order: ['soundings', 'windbarbs'],
|
|
3465
|
+
order: ['soundings', 'windbarbs', 'hodograph'],
|
|
3142
3466
|
entities: {
|
|
3143
3467
|
soundings: {
|
|
3144
3468
|
id: 'soundings',
|
|
@@ -3149,6 +3473,11 @@ const initialSoundingsState = {
|
|
|
3149
3473
|
id: 'windbarbs',
|
|
3150
3474
|
name: 'Wind Barbs',
|
|
3151
3475
|
isDisplayed: true
|
|
3476
|
+
},
|
|
3477
|
+
hodograph: {
|
|
3478
|
+
id: 'hodograph',
|
|
3479
|
+
name: 'Hodograph',
|
|
3480
|
+
isDisplayed: true
|
|
3152
3481
|
}
|
|
3153
3482
|
}
|
|
3154
3483
|
},
|
|
@@ -3174,7 +3503,23 @@ const slice = createSlice({
|
|
|
3174
3503
|
draft.selectedLocations = preset.selectedLocations ?? {};
|
|
3175
3504
|
draft.selectedTime = preset.selectedTime ?? new Date().toISOString();
|
|
3176
3505
|
draft.diagramType = preset.diagramType ?? 'SKEWT';
|
|
3177
|
-
|
|
3506
|
+
// Ensure we show all available panels, even if not stored in preset
|
|
3507
|
+
if (preset.soundingPanels) {
|
|
3508
|
+
draft.soundingPanels = {
|
|
3509
|
+
...preset.soundingPanels
|
|
3510
|
+
};
|
|
3511
|
+
initialSoundingsState.soundingPanels.order.forEach(panel => {
|
|
3512
|
+
if (!preset.soundingPanels.order.find(presetPanel => panel === presetPanel)) {
|
|
3513
|
+
draft.soundingPanels.order = [...draft.soundingPanels.order, panel];
|
|
3514
|
+
draft.soundingPanels.entities = {
|
|
3515
|
+
...draft.soundingPanels.entities,
|
|
3516
|
+
[panel]: initialSoundingsState.soundingPanels.entities[panel]
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
});
|
|
3520
|
+
} else {
|
|
3521
|
+
draft.soundingPanels = initialSoundingsState.soundingPanels;
|
|
3522
|
+
}
|
|
3178
3523
|
draft.shouldHideLocationMarkers = preset.shouldHideLocationMarkers ?? false;
|
|
3179
3524
|
}
|
|
3180
3525
|
},
|
|
@@ -3258,6 +3603,46 @@ const normalizeSoundingsState = state => {
|
|
|
3258
3603
|
};
|
|
3259
3604
|
const soundingsReducer = (state, action) => {
|
|
3260
3605
|
const normalizedState = normalizeSoundingsState(state ?? initialState);
|
|
3606
|
+
if (action.type === 'GENERIC_SYNC_SETTIME') {
|
|
3607
|
+
const {
|
|
3608
|
+
source,
|
|
3609
|
+
targets: targetsFromAction
|
|
3610
|
+
} = action.payload;
|
|
3611
|
+
const targets = [...targetsFromAction];
|
|
3612
|
+
if (source?.payload) {
|
|
3613
|
+
targets.unshift({
|
|
3614
|
+
targetId: source.payload.sourceId,
|
|
3615
|
+
value: source.payload.value
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
let hasChanges = false;
|
|
3619
|
+
const nextById = {
|
|
3620
|
+
...normalizedState.byId
|
|
3621
|
+
};
|
|
3622
|
+
targets.forEach(({
|
|
3623
|
+
targetId,
|
|
3624
|
+
value
|
|
3625
|
+
}) => {
|
|
3626
|
+
if (!targetId || !value?.currentTime || !nextById[targetId]) {
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3629
|
+
if (nextById[targetId].selectedTime === value.currentTime) {
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
hasChanges = true;
|
|
3633
|
+
nextById[targetId] = {
|
|
3634
|
+
...nextById[targetId],
|
|
3635
|
+
selectedTime: value.currentTime
|
|
3636
|
+
};
|
|
3637
|
+
});
|
|
3638
|
+
if (hasChanges) {
|
|
3639
|
+
return {
|
|
3640
|
+
...normalizedState,
|
|
3641
|
+
byId: nextById
|
|
3642
|
+
};
|
|
3643
|
+
}
|
|
3644
|
+
return normalizedState;
|
|
3645
|
+
}
|
|
3261
3646
|
if (action.type === setSoundingsCollections.type) {
|
|
3262
3647
|
const collections = action.payload;
|
|
3263
3648
|
if (collections === normalizedState.soundingsCollections) {
|
|
@@ -3349,6 +3734,7 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
|
|
|
3349
3734
|
instanceId: value.instanceId,
|
|
3350
3735
|
verticalRange: value.verticalRange,
|
|
3351
3736
|
parameterMappings: value.parameterMappings,
|
|
3737
|
+
windSpeedUnit: value.windSpeedUnit ?? 'ms',
|
|
3352
3738
|
name: value.name ?? ''
|
|
3353
3739
|
});
|
|
3354
3740
|
}
|
|
@@ -3660,16 +4046,53 @@ const SoundingsTimeSlider = ({
|
|
|
3660
4046
|
value: dateUtils.unix(dateUtils.getUtcFromString(date)),
|
|
3661
4047
|
label: shouldHideLabel(arr.length, index, maxLabelCount) ? '' : dateUtils.dateToString(dateUtils.getUtcFromString(date), 'iii d. HH:mm', true, i18n.language)
|
|
3662
4048
|
}));
|
|
4049
|
+
const currentTimeIndex = React.useMemo(() => {
|
|
4050
|
+
if (timesteps.length === 0) {
|
|
4051
|
+
return -1;
|
|
4052
|
+
}
|
|
4053
|
+
const selectedUtc = dateUtils.getUtcFromString(selectedTime);
|
|
4054
|
+
if (!selectedUtc) {
|
|
4055
|
+
return 0;
|
|
4056
|
+
}
|
|
4057
|
+
const selectedUnix = dateUtils.unix(selectedUtc);
|
|
4058
|
+
const indexByExactTimestamp = timesteps.findIndex(time => {
|
|
4059
|
+
const utcTime = dateUtils.getUtcFromString(time);
|
|
4060
|
+
if (!utcTime) {
|
|
4061
|
+
return false;
|
|
4062
|
+
}
|
|
4063
|
+
return dateUtils.unix(utcTime) === selectedUnix;
|
|
4064
|
+
});
|
|
4065
|
+
if (indexByExactTimestamp !== -1) {
|
|
4066
|
+
return indexByExactTimestamp;
|
|
4067
|
+
}
|
|
4068
|
+
const indexByExactString = timesteps.findIndex(time => time === selectedTime);
|
|
4069
|
+
if (indexByExactString !== -1) {
|
|
4070
|
+
return indexByExactString;
|
|
4071
|
+
}
|
|
4072
|
+
// If selected time is not a sounding timestep, anchor on the nearest timestep
|
|
4073
|
+
// so forward/backward navigation continues from the expected point in time.
|
|
4074
|
+
return timesteps.reduce((nearestIndex, time, index) => {
|
|
4075
|
+
const utcTime = dateUtils.getUtcFromString(time);
|
|
4076
|
+
if (!utcTime) {
|
|
4077
|
+
return nearestIndex;
|
|
4078
|
+
}
|
|
4079
|
+
const currentDiff = Math.abs(dateUtils.unix(utcTime) - selectedUnix);
|
|
4080
|
+
const nearestUtc = dateUtils.getUtcFromString(timesteps[nearestIndex]);
|
|
4081
|
+
if (!nearestUtc) {
|
|
4082
|
+
return index;
|
|
4083
|
+
}
|
|
4084
|
+
const nearestDiff = Math.abs(dateUtils.unix(nearestUtc) - selectedUnix);
|
|
4085
|
+
return currentDiff < nearestDiff ? index : nearestIndex;
|
|
4086
|
+
}, 0);
|
|
4087
|
+
}, [selectedTime, timesteps]);
|
|
3663
4088
|
const onStepBackward = React.useCallback(() => {
|
|
3664
|
-
const
|
|
3665
|
-
const precedingIndex = Math.max(currentIndex - 1, 0);
|
|
4089
|
+
const precedingIndex = Math.max(currentTimeIndex - 1, 0);
|
|
3666
4090
|
updateSelectedTime(timesteps[precedingIndex]);
|
|
3667
|
-
}, [
|
|
4091
|
+
}, [currentTimeIndex, updateSelectedTime, timesteps]);
|
|
3668
4092
|
const onStepForward = React.useCallback(() => {
|
|
3669
|
-
const
|
|
3670
|
-
const followingIndex = Math.min(currentIndex + 1, timesteps.length - 1);
|
|
4093
|
+
const followingIndex = Math.min(currentTimeIndex + 1, timesteps.length - 1);
|
|
3671
4094
|
updateSelectedTime(timesteps[followingIndex]);
|
|
3672
|
-
}, [
|
|
4095
|
+
}, [currentTimeIndex, updateSelectedTime, timesteps]);
|
|
3673
4096
|
return jsxs("div", {
|
|
3674
4097
|
ref: sliderRef,
|
|
3675
4098
|
style: {
|
|
@@ -3739,47 +4162,50 @@ const SoundingsTimeSlider = ({
|
|
|
3739
4162
|
});
|
|
3740
4163
|
};
|
|
3741
4164
|
|
|
3742
|
-
const roundToClosestTimestep = (timeToRound, availableSteps) => {
|
|
3743
|
-
if (availableSteps.length > 0 && !availableSteps.includes(timeToRound)) {
|
|
3744
|
-
const selectionValue = dateUtils.unix(new Date(timeToRound));
|
|
3745
|
-
const closestTimestep = minBy(availableSteps, timeValue =>
|
|
3746
|
-
// distance to selected time
|
|
3747
|
-
Math.abs(selectionValue - dateUtils.unix(new Date(timeValue))));
|
|
3748
|
-
return closestTimestep || availableSteps[0];
|
|
3749
|
-
}
|
|
3750
|
-
return timeToRound;
|
|
3751
|
-
};
|
|
3752
4165
|
const SoundingsTimeSliderConnect = ({
|
|
3753
|
-
viewId
|
|
4166
|
+
viewId,
|
|
4167
|
+
timeInUse,
|
|
4168
|
+
syncEnabled = false
|
|
3754
4169
|
}) => {
|
|
3755
|
-
const
|
|
4170
|
+
const selectedTimeInStore = useSelector(state => getSelectedTime(state, viewId));
|
|
4171
|
+
const selectedTime = timeInUse || selectedTimeInStore;
|
|
3756
4172
|
const sourceSelections = useSelector(state => getSelectedSources(state, viewId));
|
|
3757
4173
|
const dispatch = useDispatch();
|
|
3758
|
-
const updateSelectedTime = React.useCallback(
|
|
4174
|
+
const updateSelectedTime = React.useCallback(selectedTime => {
|
|
4175
|
+
if (syncEnabled) {
|
|
4176
|
+
dispatch(genericActions.setTime({
|
|
4177
|
+
sourceId: viewId,
|
|
4178
|
+
origin: 'SoundingsTimeSliderConnect',
|
|
4179
|
+
value: {
|
|
4180
|
+
currentTime: handleDateUtilsISOString(selectedTime)
|
|
4181
|
+
}
|
|
4182
|
+
}));
|
|
4183
|
+
return;
|
|
4184
|
+
}
|
|
3759
4185
|
dispatch(soundingsActions.updateSelectedTime({
|
|
3760
4186
|
selectedTime,
|
|
3761
4187
|
panelId: viewId,
|
|
3762
|
-
origin:
|
|
4188
|
+
origin: SoundingsActionOrigin.soundingsComponent
|
|
3763
4189
|
}));
|
|
3764
|
-
}, [dispatch, viewId]);
|
|
4190
|
+
}, [dispatch, syncEnabled, viewId]);
|
|
3765
4191
|
const {
|
|
3766
4192
|
data: timesteps
|
|
3767
4193
|
} = useTimesteps(sourceSelections);
|
|
3768
4194
|
const mergedTimesteps = [...new Set(timesteps.flat())].sort();
|
|
3769
|
-
const
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
const isInitial = !hasInitializedTime.current;
|
|
3775
|
-
updateSelectedTime(validSelectedTime, isInitial);
|
|
3776
|
-
if (isInitial) {
|
|
3777
|
-
hasInitializedTime.current = true;
|
|
3778
|
-
}
|
|
4195
|
+
const validSelectedTime = mergedTimesteps.length > 0 && !mergedTimesteps.includes(selectedTimeInStore) ? getNearestTimestep(selectedTimeInStore, mergedTimesteps) : selectedTimeInStore;
|
|
4196
|
+
React.useEffect(() => {
|
|
4197
|
+
// Local mode: keep selectedTime aligned with available timesteps.
|
|
4198
|
+
if (syncEnabled || selectedTimeInStore === validSelectedTime) {
|
|
4199
|
+
return;
|
|
3779
4200
|
}
|
|
3780
|
-
|
|
4201
|
+
dispatch(soundingsActions.updateSelectedTime({
|
|
4202
|
+
selectedTime: validSelectedTime,
|
|
4203
|
+
panelId: viewId,
|
|
4204
|
+
origin: SoundingsActionOrigin.soundingsComponent
|
|
4205
|
+
}));
|
|
4206
|
+
}, [dispatch, selectedTimeInStore, syncEnabled, validSelectedTime, viewId]);
|
|
3781
4207
|
return jsx(SoundingsTimeSlider, {
|
|
3782
|
-
selectedTime:
|
|
4208
|
+
selectedTime: selectedTime,
|
|
3783
4209
|
timesteps: mergedTimesteps,
|
|
3784
4210
|
updateSelectedTime: updateSelectedTime
|
|
3785
4211
|
});
|
|
@@ -3816,6 +4242,9 @@ const SoundingsConnect = ({
|
|
|
3816
4242
|
viewId,
|
|
3817
4243
|
soundingsPreset
|
|
3818
4244
|
}) => {
|
|
4245
|
+
const {
|
|
4246
|
+
t
|
|
4247
|
+
} = useSoundingsTranslation();
|
|
3819
4248
|
const viewRef = React.useRef(null);
|
|
3820
4249
|
const size = useGetSize(viewRef);
|
|
3821
4250
|
const geojsonLayerId = React.useRef(webmapUtils.generateLayerId()).current;
|
|
@@ -3852,6 +4281,25 @@ const SoundingsConnect = ({
|
|
|
3852
4281
|
const locationSelections = useSelector(state => getLocationSelections(state, viewId), shallowEqual);
|
|
3853
4282
|
const diagramType = useSelector(state => getDiagramType(state, viewId));
|
|
3854
4283
|
const shouldHideLocationMarkers = useSelector(state => getShouldHideLocationMarkers(state, viewId));
|
|
4284
|
+
const selectedMapIds = useSelector(state => genericSelectors.getLinkedMaps(state, viewId), shallowEqual);
|
|
4285
|
+
const firstLinkedMapId = selectedMapIds[0];
|
|
4286
|
+
const {
|
|
4287
|
+
data: sourceTimesteps
|
|
4288
|
+
} = useTimesteps(sourceSelections);
|
|
4289
|
+
const syncgroupTimeState = useSyncgroupTimeState(viewId, 0);
|
|
4290
|
+
const syncgroupSelectedTime = useMemo(() => {
|
|
4291
|
+
if (syncgroupTimeState?.current === undefined || !Number.isFinite(syncgroupTimeState.current)) {
|
|
4292
|
+
return undefined;
|
|
4293
|
+
}
|
|
4294
|
+
return dateUtils.fromUnix(syncgroupTimeState.current).toISOString();
|
|
4295
|
+
}, [syncgroupTimeState]);
|
|
4296
|
+
const isTimeSynced = !!syncgroupSelectedTime;
|
|
4297
|
+
const selectedTimeInUse = syncgroupSelectedTime || selectedTime;
|
|
4298
|
+
const timeMismatchInfo = useMemo(() => {
|
|
4299
|
+
return getTimeMismatchInfo(isTimeSynced, selectedTimeInUse, sourceTimesteps);
|
|
4300
|
+
}, [isTimeSynced, selectedTimeInUse, sourceTimesteps]);
|
|
4301
|
+
const mismatchKey = timeMismatchInfo ? selectedTimeInUse || '' : '';
|
|
4302
|
+
const [isTimeMismatchDismissed, setIsTimeMismatchDismissed] = React.useState(false);
|
|
3855
4303
|
const setChosenDiagramType = React.useCallback(diagramType => {
|
|
3856
4304
|
dispatch(soundingsActions.updateDiagramType({
|
|
3857
4305
|
diagramType,
|
|
@@ -3860,11 +4308,9 @@ const SoundingsConnect = ({
|
|
|
3860
4308
|
}));
|
|
3861
4309
|
}, [dispatch, viewId]);
|
|
3862
4310
|
const selectedFeature = useSelector(state => genericSelectors.getSelectedFeature(state.syncGroups, viewId), shallowEqual);
|
|
3863
|
-
const selectedMapIds = useSelector(state => genericSelectors.getLinkedMaps(state, viewId), shallowEqual);
|
|
3864
|
-
const firstMapId = selectedMapIds[0];
|
|
3865
4311
|
const selectedMapPin = useSelector(state =>
|
|
3866
4312
|
// mapPins are synced so we can arbitrarily choose the first linked map
|
|
3867
|
-
mapSelectors.getPinLocation(state,
|
|
4313
|
+
mapSelectors.getPinLocation(state, firstLinkedMapId), shallowEqual);
|
|
3868
4314
|
const collectionsRef = React.useRef();
|
|
3869
4315
|
const collections = useMemo(() => {
|
|
3870
4316
|
const newCollections = sourceSelections.map(({
|
|
@@ -4082,13 +4528,17 @@ const SoundingsConnect = ({
|
|
|
4082
4528
|
error,
|
|
4083
4529
|
data,
|
|
4084
4530
|
isPending
|
|
4085
|
-
} = useMultipleSources(sourceSelections, locationSelections,
|
|
4531
|
+
} = useMultipleSources(sourceSelections, locationSelections, selectedTimeInUse, 'exact');
|
|
4532
|
+
useEffect(() => {
|
|
4533
|
+
setIsTimeMismatchDismissed(false);
|
|
4534
|
+
}, [mismatchKey]);
|
|
4086
4535
|
const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store, viewId));
|
|
4087
4536
|
const changeOrder = newOrder => {
|
|
4088
4537
|
const orderedIds = newOrder.map(collection => collection.id);
|
|
4089
4538
|
dispatch(soundingsActions.changeSoundingPanelOrder({
|
|
4090
4539
|
panelId: viewId,
|
|
4091
|
-
newOrderOfIds: orderedIds
|
|
4540
|
+
newOrderOfIds: orderedIds,
|
|
4541
|
+
origin: SoundingsActionOrigin.soundingsComponent
|
|
4092
4542
|
}));
|
|
4093
4543
|
};
|
|
4094
4544
|
return jsxs(Fragment, {
|
|
@@ -4099,14 +4549,18 @@ const SoundingsConnect = ({
|
|
|
4099
4549
|
color: "secondary",
|
|
4100
4550
|
sx: {
|
|
4101
4551
|
position: 'absolute',
|
|
4102
|
-
|
|
4552
|
+
top: 0,
|
|
4553
|
+
left: 0,
|
|
4554
|
+
width: '100%',
|
|
4555
|
+
zIndex: 20
|
|
4103
4556
|
}
|
|
4104
4557
|
}), jsxs(Stack, {
|
|
4105
4558
|
sx: {
|
|
4106
4559
|
padding: '12px',
|
|
4107
4560
|
width: '100%',
|
|
4108
4561
|
height: '100%',
|
|
4109
|
-
backgroundColor: 'geowebColors.background.surfaceApp'
|
|
4562
|
+
backgroundColor: 'geowebColors.background.surfaceApp',
|
|
4563
|
+
position: 'relative'
|
|
4110
4564
|
},
|
|
4111
4565
|
children: [jsx(Box, {
|
|
4112
4566
|
paddingLeft: 5,
|
|
@@ -4116,7 +4570,8 @@ const SoundingsConnect = ({
|
|
|
4116
4570
|
}), jsx(Card, {
|
|
4117
4571
|
sx: {
|
|
4118
4572
|
width: '100%',
|
|
4119
|
-
height: '100%'
|
|
4573
|
+
height: '100%',
|
|
4574
|
+
position: 'relative'
|
|
4120
4575
|
},
|
|
4121
4576
|
children: jsxs(Stack, {
|
|
4122
4577
|
spacing: 1,
|
|
@@ -4125,7 +4580,30 @@ const SoundingsConnect = ({
|
|
|
4125
4580
|
height: '100%',
|
|
4126
4581
|
display: 'flex'
|
|
4127
4582
|
},
|
|
4128
|
-
children: [
|
|
4583
|
+
children: [!!timeMismatchInfo && !isTimeMismatchDismissed && jsxs(Box, {
|
|
4584
|
+
sx: {
|
|
4585
|
+
flex: '0 0 auto',
|
|
4586
|
+
position: 'relative'
|
|
4587
|
+
},
|
|
4588
|
+
children: [jsx(AlertBanner, {
|
|
4589
|
+
severity: "warning",
|
|
4590
|
+
title: t('soundings-time-mismatch-title')
|
|
4591
|
+
}), jsx(IconButton, {
|
|
4592
|
+
"aria-label": "Close warning",
|
|
4593
|
+
onClick: () => setIsTimeMismatchDismissed(true),
|
|
4594
|
+
size: "small",
|
|
4595
|
+
sx: {
|
|
4596
|
+
position: 'absolute',
|
|
4597
|
+
right: 8,
|
|
4598
|
+
top: 10,
|
|
4599
|
+
padding: 0.25,
|
|
4600
|
+
zIndex: 1
|
|
4601
|
+
},
|
|
4602
|
+
children: jsx(Close, {
|
|
4603
|
+
fontSize: "small"
|
|
4604
|
+
})
|
|
4605
|
+
})]
|
|
4606
|
+
}), jsx(Box, {
|
|
4129
4607
|
sx: {
|
|
4130
4608
|
display: 'flex',
|
|
4131
4609
|
justifyContent: 'center',
|
|
@@ -4161,6 +4639,8 @@ const SoundingsConnect = ({
|
|
|
4161
4639
|
height: `${TIMESLIDER_HEIGHT}px`
|
|
4162
4640
|
},
|
|
4163
4641
|
children: jsx(SoundingsTimeSliderConnect, {
|
|
4642
|
+
syncEnabled: isTimeSynced,
|
|
4643
|
+
timeInUse: selectedTimeInUse,
|
|
4164
4644
|
viewId: viewId
|
|
4165
4645
|
})
|
|
4166
4646
|
})]
|
|
@@ -4360,6 +4840,7 @@ const SoundingsSourceDialogConnect = ({
|
|
|
4360
4840
|
url: collection.url,
|
|
4361
4841
|
isChecked: currentSelection?.isChecked || false,
|
|
4362
4842
|
parameterMappings: collection.parameterMappings,
|
|
4843
|
+
windSpeedUnit: collection.windSpeedUnit,
|
|
4363
4844
|
...(collection.name && collection.name !== '' ? {
|
|
4364
4845
|
name: collection.name
|
|
4365
4846
|
} : {})
|
|
@@ -4589,28 +5070,6 @@ const LocationsMenuConnect = ({
|
|
|
4589
5070
|
});
|
|
4590
5071
|
};
|
|
4591
5072
|
|
|
4592
|
-
var SoundingManagerDummyPreset = {
|
|
4593
|
-
order: [
|
|
4594
|
-
"soundings",
|
|
4595
|
-
"windbarbs"
|
|
4596
|
-
],
|
|
4597
|
-
entities: {
|
|
4598
|
-
soundings: {
|
|
4599
|
-
id: "soundings",
|
|
4600
|
-
name: "Soundings",
|
|
4601
|
-
isDisplayed: true
|
|
4602
|
-
},
|
|
4603
|
-
windbarbs: {
|
|
4604
|
-
id: "windbarbs",
|
|
4605
|
-
name: "Wind Barbs",
|
|
4606
|
-
isDisplayed: true
|
|
4607
|
-
}
|
|
4608
|
-
}
|
|
4609
|
-
};
|
|
4610
|
-
var preset = {
|
|
4611
|
-
SoundingManagerDummyPreset: SoundingManagerDummyPreset
|
|
4612
|
-
};
|
|
4613
|
-
|
|
4614
5073
|
const SoundingManagerListItem = ({
|
|
4615
5074
|
id,
|
|
4616
5075
|
name,
|
|
@@ -4723,24 +5182,20 @@ const SoundingsManagerConnect = () => {
|
|
|
4723
5182
|
} = useSetupDialog(uiTypes.DialogTypes.SoundingsManager);
|
|
4724
5183
|
const dispatch = useDispatch();
|
|
4725
5184
|
const panelId = useSelector(store => uiSelectors.getDialogMapId(store, uiTypes.DialogTypes.SoundingsManager));
|
|
4726
|
-
React.useEffect(() => {
|
|
4727
|
-
const data = preset.SoundingManagerDummyPreset;
|
|
4728
|
-
dispatch(soundingsActions.addSoundingPanelsFromPreset({
|
|
4729
|
-
panelId,
|
|
4730
|
-
panels: data
|
|
4731
|
-
}));
|
|
4732
|
-
}, [dispatch, panelId]);
|
|
4733
5185
|
const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store, panelId));
|
|
4734
5186
|
const changeOrder = newOrder => {
|
|
4735
5187
|
const orderedIds = newOrder.map(collection => collection.id);
|
|
4736
5188
|
dispatch(soundingsActions.changeSoundingPanelOrder({
|
|
4737
5189
|
panelId,
|
|
4738
|
-
newOrderOfIds: orderedIds
|
|
5190
|
+
newOrderOfIds: orderedIds,
|
|
5191
|
+
origin: 'soundingsManager'
|
|
4739
5192
|
}));
|
|
4740
5193
|
};
|
|
4741
5194
|
const toggleVisibility = id => {
|
|
4742
5195
|
dispatch(soundingsActions.toggleSoundingPanelVisibility({
|
|
4743
|
-
id
|
|
5196
|
+
id,
|
|
5197
|
+
panelId,
|
|
5198
|
+
origin: 'soundingsManager'
|
|
4744
5199
|
}));
|
|
4745
5200
|
};
|
|
4746
5201
|
return jsx(SoundingsManager, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeoweb/soundings",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "16.0.0",
|
|
4
4
|
"description": "GeoWeb soundings library for the opengeoweb project",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -8,24 +8,24 @@
|
|
|
8
8
|
"url": "git@gitlab.com:opengeoweb/opengeoweb.git"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@opengeoweb/api": "
|
|
11
|
+
"@opengeoweb/api": "16.0.0",
|
|
12
12
|
"@tanstack/react-query": "^5.85.5",
|
|
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": "
|
|
17
|
+
"@opengeoweb/shared": "16.0.0",
|
|
18
18
|
"@mui/material": "^7.0.1",
|
|
19
|
-
"@opengeoweb/store": "
|
|
19
|
+
"@opengeoweb/store": "16.0.0",
|
|
20
20
|
"react-redux": "^9.2.0",
|
|
21
|
-
"@opengeoweb/theme": "
|
|
21
|
+
"@opengeoweb/theme": "16.0.0",
|
|
22
22
|
"@reduxjs/toolkit": "^2.6.1",
|
|
23
|
-
"@opengeoweb/time-slider": "
|
|
23
|
+
"@opengeoweb/time-slider": "16.0.0",
|
|
24
24
|
"react-window": "^2.2.6",
|
|
25
|
-
"@opengeoweb/webmap": "
|
|
25
|
+
"@opengeoweb/webmap": "16.0.0",
|
|
26
26
|
"lodash": "^4.17.21",
|
|
27
27
|
"@types/geojson": "^7946.0.14",
|
|
28
|
-
"@opengeoweb/webmap-react": "
|
|
28
|
+
"@opengeoweb/webmap-react": "16.0.0",
|
|
29
29
|
"react-resizable-layout": "^0.7.3",
|
|
30
30
|
"react-sortablejs": "^6.1.4"
|
|
31
31
|
},
|
|
@@ -17,7 +17,7 @@ export declare const defaultParameterMappings: {
|
|
|
17
17
|
relativeHumidity: string;
|
|
18
18
|
};
|
|
19
19
|
export declare const getParameterMappings: (parameterMappings: Record<string, string>) => string[];
|
|
20
|
-
export declare const useMultipleSources: (sources: SourceType[], locations: Record<string, string | undefined | Coordinates>, datetime?: string) => {
|
|
20
|
+
export declare const useMultipleSources: (sources: SourceType[], locations: Record<string, string | undefined | Coordinates>, datetime?: string, timeSelectionMode?: "exact" | "nearestWithinRange") => {
|
|
21
21
|
data: SoundingFeature[][];
|
|
22
22
|
isPending: boolean;
|
|
23
23
|
error: Error | null | undefined;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Coverage, CoverageCollection, VerticalProfileDomain } from '@opengeoweb/shared';
|
|
2
2
|
import type { CollectionDetails, SoundingFeature, SoundingsInstance } from './types';
|
|
3
|
-
export declare
|
|
3
|
+
export declare const getWindSpeedInKts: (windSpeed: number, windSpeedUnit: string) => number;
|
|
4
|
+
export declare const getNearestTimestep: (requestedDatetime: string, availableTimesteps: string[]) => string;
|
|
5
|
+
export declare function parseCoverageCollection(response: CoverageCollection<VerticalProfileDomain> | Coverage<VerticalProfileDomain>, parameterMappings: Record<string, string>, windSpeedUnit: string): SoundingFeature[];
|
|
4
6
|
/**
|
|
5
7
|
* Parse duration with repeats
|
|
6
8
|
* @param dateString e.g. 'R67/2025-04-22T09:00:00Z/PT60M'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
|
2
|
+
import { HodographCollection } from './HodographCollection';
|
|
3
|
+
declare const meta: Meta<typeof HodographCollection>;
|
|
4
|
+
export default meta;
|
|
5
|
+
type Story = StoryObj<typeof HodographCollection>;
|
|
6
|
+
export declare const HodographStory: Story;
|
|
7
|
+
export declare const HodographStoryDark: Story;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as d3 from 'd3';
|
|
2
|
+
import type { ScalesType } from '../Soundings/types';
|
|
3
|
+
interface InitializeSvg {
|
|
4
|
+
svgRef: React.MutableRefObject<SVGSVGElement | null>;
|
|
5
|
+
widthHeight: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const initializeSvg: ({ svgRef, widthHeight, }: InitializeSvg) => d3.Selection<SVGSVGElement | null, unknown, null, undefined>;
|
|
8
|
+
export declare const createScales: (widthHeight: number) => [ScalesType, ScalesType];
|
|
9
|
+
interface DrawAxis {
|
|
10
|
+
svg: d3.Selection<SVGSVGElement, unknown, HTMLElement, undefined>;
|
|
11
|
+
scaleRadius: ScalesType;
|
|
12
|
+
scaleAngle: ScalesType;
|
|
13
|
+
widthHeight: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const drawAxes: ({ svg, scaleRadius, scaleAngle, widthHeight, }: DrawAxis) => void;
|
|
16
|
+
export declare const filterDomainRangeForHodograph: (pressureDomain: number[], parameterDomain: [number, number][]) => [number[], [number, number][]];
|
|
17
|
+
type PathSegments = [
|
|
18
|
+
[
|
|
19
|
+
number,
|
|
20
|
+
number
|
|
21
|
+
][],
|
|
22
|
+
[
|
|
23
|
+
number,
|
|
24
|
+
number
|
|
25
|
+
][],
|
|
26
|
+
[
|
|
27
|
+
number,
|
|
28
|
+
number
|
|
29
|
+
][],
|
|
30
|
+
[
|
|
31
|
+
number,
|
|
32
|
+
number
|
|
33
|
+
][],
|
|
34
|
+
[
|
|
35
|
+
number,
|
|
36
|
+
number
|
|
37
|
+
][]
|
|
38
|
+
];
|
|
39
|
+
export declare const altitudeBarometricFromPressure: (pressure: number) => number;
|
|
40
|
+
export declare const generateSegmentsBasedOnLevel: (pressure: number[], windValues: [number, number][]) => PathSegments;
|
|
41
|
+
export declare const HODOGRAPH_COLORS: string[];
|
|
42
|
+
export declare const getColorForSegment: (index: number) => string;
|
|
43
|
+
interface PlotHodograph {
|
|
44
|
+
windValues: [number, number][];
|
|
45
|
+
pressureValues: number[];
|
|
46
|
+
scaleAngle: ScalesType;
|
|
47
|
+
scaleRadius: ScalesType;
|
|
48
|
+
widthHeight: number;
|
|
49
|
+
svg: d3.Selection<SVGSVGElement, unknown, HTMLElement, undefined>;
|
|
50
|
+
index: number;
|
|
51
|
+
}
|
|
52
|
+
export declare const plotHodograph: ({ windValues, pressureValues, scaleAngle, scaleRadius, widthHeight, svg, index, }: PlotHodograph) => void;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -8,6 +8,7 @@ export default _default;
|
|
|
8
8
|
type Story = StoryObj<typeof SoundingsConnect>;
|
|
9
9
|
export declare const SoundingsConnectStory: Story;
|
|
10
10
|
export declare const SoundingsConnectStoryDark: Story;
|
|
11
|
+
export declare const SoundingsConnectWarningStory: Story;
|
|
11
12
|
export declare const SoundingsClosedWindBarbsOpenStory: Story;
|
|
12
13
|
export declare const SoundingsClosedWindBarbsOpenStoryDark: Story;
|
|
13
14
|
export declare const SoundingsConnectLoading: {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
interface SoundingsTimeSliderConnectProps {
|
|
3
3
|
viewId: string;
|
|
4
|
+
timeInUse?: string;
|
|
5
|
+
syncEnabled?: boolean;
|
|
4
6
|
}
|
|
5
|
-
export declare const SoundingsTimeSliderConnect: ({ viewId, }: SoundingsTimeSliderConnectProps) => React.ReactElement;
|
|
7
|
+
export declare const SoundingsTimeSliderConnect: ({ viewId, timeInUse, syncEnabled, }: SoundingsTimeSliderConnectProps) => React.ReactElement;
|
|
6
8
|
export {};
|