@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 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, id, source url and description and include parameter mappings telling the soundings API which EDR parameters to assign to which physical property.
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, Card, Stack, IconButton, Tooltip, LinearProgress, List, ListItem, ListItemIcon, Checkbox, ListItemText, Grid, ListItemButton } from '@mui/material';
3
- import { range, minBy, isEqual } from 'lodash';
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, mapSelectors, genericActions, syncGroupsActions, mapActions, useSetupDialog, storeReducerMap, storeMiddlewares } from '@opengeoweb/store';
8
- import { ParseISOTimeRangeDuration, parseISO8601IntervalToDateInterval, webmapUtils } from '@opengeoweb/webmap';
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-toggle-location-markers-label": "ikke oversatt",
51
- "soundings-toggle-location-markers-tooltip": "ikke oversatt"
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, 'm/s', index === interpolatedData.length - 1, dataSet.color, true);
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
- }) => valueKts >= min && valueKts < max);
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
- // Find the best matching timestep for this source
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
- const availableTimesteps = timesteps[index];
2878
- // Check for exact match first
2879
- if (availableTimesteps.includes(new Date(datetime).toISOString())) {
2880
- dateToUse = datetime;
2881
- } else {
2882
- // Find nearest timestep (prefer earlier timesteps if equidistant)
2883
- let nearestTimestep = availableTimesteps[0];
2884
- let minDiff = Math.abs(new Date(availableTimesteps[0]).getTime() - requestedTime);
2885
- for (const timestep of availableTimesteps) {
2886
- const diff = Math.abs(new Date(timestep).getTime() - requestedTime);
2887
- if (diff < minDiff) {
2888
- minDiff = diff;
2889
- nearestTimestep = timestep;
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
- draft.soundingPanels = preset.soundingPanels ?? initialSoundingsState.soundingPanels;
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 currentIndex = timesteps.indexOf(selectedTime);
3665
- const precedingIndex = Math.max(currentIndex - 1, 0);
4089
+ const precedingIndex = Math.max(currentTimeIndex - 1, 0);
3666
4090
  updateSelectedTime(timesteps[precedingIndex]);
3667
- }, [selectedTime, updateSelectedTime, timesteps]);
4091
+ }, [currentTimeIndex, updateSelectedTime, timesteps]);
3668
4092
  const onStepForward = React.useCallback(() => {
3669
- const currentIndex = timesteps.indexOf(selectedTime);
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
- }, [selectedTime, updateSelectedTime, timesteps]);
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 selectedTime = useSelector(state => getSelectedTime(state, viewId));
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((selectedTime, isInitialCorrection = false) => {
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: isInitialCorrection ? SoundingsActionOrigin.soundingsManager : SoundingsActionOrigin.soundingsComponent
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 hasInitializedTime = React.useRef(false);
3770
- // Ensure that the selected time is always one of the available timesteps
3771
- const validSelectedTime = roundToClosestTimestep(selectedTime, mergedTimesteps);
3772
- useEffect(() => {
3773
- if (selectedTime !== validSelectedTime) {
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
- }, [selectedTime, updateSelectedTime, validSelectedTime]);
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: validSelectedTime,
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, firstMapId), shallowEqual);
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, selectedTime);
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
- width: '100%'
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: [jsx(Box, {
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": "15.2.0",
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": "15.2.0",
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": "15.2.0",
17
+ "@opengeoweb/shared": "16.0.0",
18
18
  "@mui/material": "^7.0.1",
19
- "@opengeoweb/store": "15.2.0",
19
+ "@opengeoweb/store": "16.0.0",
20
20
  "react-redux": "^9.2.0",
21
- "@opengeoweb/theme": "15.2.0",
21
+ "@opengeoweb/theme": "16.0.0",
22
22
  "@reduxjs/toolkit": "^2.6.1",
23
- "@opengeoweb/time-slider": "15.2.0",
23
+ "@opengeoweb/time-slider": "16.0.0",
24
24
  "react-window": "^2.2.6",
25
- "@opengeoweb/webmap": "15.2.0",
25
+ "@opengeoweb/webmap": "16.0.0",
26
26
  "lodash": "^4.17.21",
27
27
  "@types/geojson": "^7946.0.14",
28
- "@opengeoweb/webmap-react": "15.2.0",
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 function parseCoverageCollection(response: CoverageCollection<VerticalProfileDomain> | Coverage<VerticalProfileDomain>, parameterMappings: Record<string, string>): SoundingFeature[];
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,6 @@
1
+ import React from 'react';
2
+ import type { SoundingFeature } from '../../api/edr-api/types';
3
+ export interface HodographProps {
4
+ data: SoundingFeature[][];
5
+ }
6
+ export declare const Hodograph: React.FC<HodographProps>;
@@ -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,6 @@
1
+ import React from 'react';
2
+ import type { SoundingFeature } from '../../api/edr-api/types';
3
+ export interface HodographCollectionProps {
4
+ data: SoundingFeature[][];
5
+ }
6
+ export declare const HodographCollection: React.FC<HodographCollectionProps>;
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const HodographLegend: () => React.ReactElement;
@@ -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: {
@@ -0,0 +1,4 @@
1
+ export interface TimeMismatchInfo {
2
+ mismatch: true;
3
+ }
4
+ export declare const getTimeMismatchInfo: (isTimeSynced: boolean, warningTimeInUse: string | undefined, sourceTimesteps: string[][]) => TimeMismatchInfo | undefined;
@@ -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 {};
@@ -10,6 +10,7 @@ export interface SourceType {
10
10
  instanceId?: string;
11
11
  verticalRange?: string;
12
12
  parameterMappings?: Record<string, string>;
13
+ windSpeedUnit?: string;
13
14
  name?: string;
14
15
  }
15
16
  export interface SoundingsPayload {