@opengeoweb/soundings 15.2.0 → 15.3.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 +303 -54
- package/package.json +8 -8
- package/src/lib/api/edr-api/utils.d.ts +2 -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/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,5 +1,5 @@
|
|
|
1
1
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
2
|
-
import { Menu, ListSubheader, Typography, MenuItem, Box,
|
|
2
|
+
import { Menu, ListSubheader, Typography, MenuItem, Box, Stack, Card, IconButton, Tooltip, LinearProgress, List, ListItem, ListItemIcon, Checkbox, ListItemText, Grid, ListItemButton } from '@mui/material';
|
|
3
3
|
import { range, minBy, 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';
|
|
@@ -326,7 +326,7 @@ const getDiagramName = (t, diagramType) => {
|
|
|
326
326
|
}
|
|
327
327
|
};
|
|
328
328
|
// vfx code
|
|
329
|
-
const initializeSvg = ({
|
|
329
|
+
const initializeSvg$1 = ({
|
|
330
330
|
svgRef,
|
|
331
331
|
width,
|
|
332
332
|
height,
|
|
@@ -345,7 +345,7 @@ const initializeSvg = ({
|
|
|
345
345
|
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
346
|
return svg;
|
|
347
347
|
};
|
|
348
|
-
const createScales = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
|
|
348
|
+
const createScales$1 = (diagramType, width, height, minPressure, maxPressure, minTemperature, maxTemperature) => {
|
|
349
349
|
if (diagramType === 'BIJVOET') {
|
|
350
350
|
// Note, for Bijvoet, the x-axis is NOT temperature but theta_es
|
|
351
351
|
// Both axis aren't linear nor logarithmic, hence we need an interpolation function to determine (x,y) based on (theta_es,p)
|
|
@@ -358,7 +358,7 @@ const createScales = (diagramType, width, height, minPressure, maxPressure, minT
|
|
|
358
358
|
const yScale = d3.scaleLog().domain([maxPressure, minPressure]).range([height, 0]);
|
|
359
359
|
return [xScale, yScale];
|
|
360
360
|
};
|
|
361
|
-
const drawAxes = ({
|
|
361
|
+
const drawAxes$1 = ({
|
|
362
362
|
svg,
|
|
363
363
|
xScale,
|
|
364
364
|
yScale,
|
|
@@ -697,7 +697,7 @@ const drawSoundingsTooltip = ({
|
|
|
697
697
|
// Wind
|
|
698
698
|
windRefd3?.style('display', 'block').style('left', `${width + margin.left + margin.right}px`).style('top', `${pressureY}px`).html(() => {
|
|
699
699
|
const windSpeedStrings = interpolatedData.map((dataSet, index) => {
|
|
700
|
-
return getParamHtml(dataSet.hoveredWindSpeed, index, '
|
|
700
|
+
return getParamHtml(dataSet.hoveredWindSpeed, index, 'kts', index === interpolatedData.length - 1, dataSet.color, true);
|
|
701
701
|
});
|
|
702
702
|
const windDirStrings = interpolatedData.map((dataSet, index) => {
|
|
703
703
|
return getParamHtml(dataSet.hoveredWindDirection, index, '°', index === interpolatedData.length - 1, dataSet.color, index === interpolatedData.length - 1);
|
|
@@ -1231,7 +1231,7 @@ const Soundings = ({
|
|
|
1231
1231
|
if (!svgRef.current) {
|
|
1232
1232
|
return;
|
|
1233
1233
|
}
|
|
1234
|
-
const svg = initializeSvg({
|
|
1234
|
+
const svg = initializeSvg$1({
|
|
1235
1235
|
svgRef,
|
|
1236
1236
|
width,
|
|
1237
1237
|
height,
|
|
@@ -1245,9 +1245,9 @@ const Soundings = ({
|
|
|
1245
1245
|
widthRef.current = width;
|
|
1246
1246
|
const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
|
|
1247
1247
|
const maxT = maxTemperature;
|
|
1248
|
-
const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1248
|
+
const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1249
1249
|
yScaleRef.current = yScale;
|
|
1250
|
-
drawAxes({
|
|
1250
|
+
drawAxes$1({
|
|
1251
1251
|
svg,
|
|
1252
1252
|
xScale,
|
|
1253
1253
|
yScale,
|
|
@@ -1334,7 +1334,7 @@ const Soundings = ({
|
|
|
1334
1334
|
const dewpointRefd3 = d3.select(dewpointRef.current);
|
|
1335
1335
|
const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
|
|
1336
1336
|
const maxT = maxTemperature;
|
|
1337
|
-
const [, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1337
|
+
const [, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1338
1338
|
drawSoundingsTooltip({
|
|
1339
1339
|
event: synthetic,
|
|
1340
1340
|
yScale,
|
|
@@ -1738,7 +1738,6 @@ const windSpeedRanges = [{
|
|
|
1738
1738
|
* Copyright 2025 - Finnish Meteorological Institute (FMI)
|
|
1739
1739
|
* Copyright 2025 - The Norwegian Meteorological Institute (MET Norway)
|
|
1740
1740
|
* */
|
|
1741
|
-
const metersPerSecondToKnots = metersPerSecond => metersPerSecond * 1.94384;
|
|
1742
1741
|
const extendIsoBar = (path, horizontalBarbMargin) => {
|
|
1743
1742
|
const match = /^M([\d.-]+),([\d.-]+)\s*L([\d.-]+),([\d.-]+)/.exec(path);
|
|
1744
1743
|
if (match) {
|
|
@@ -1846,11 +1845,10 @@ const removeItemsExponential = array => {
|
|
|
1846
1845
|
return array.filter((_, index) => indicesToKeep.includes(index));
|
|
1847
1846
|
};
|
|
1848
1847
|
const getWindSymbol = value => {
|
|
1849
|
-
const valueKts = metersPerSecondToKnots(value);
|
|
1850
1848
|
const matchedRange = windSpeedRanges.find(({
|
|
1851
1849
|
min,
|
|
1852
1850
|
max
|
|
1853
|
-
}) =>
|
|
1851
|
+
}) => value >= min && value < max);
|
|
1854
1852
|
// Out of range
|
|
1855
1853
|
if (!matchedRange) {
|
|
1856
1854
|
return null;
|
|
@@ -1929,7 +1927,7 @@ const WindBarbs = ({
|
|
|
1929
1927
|
}
|
|
1930
1928
|
const root = d3.select(svgRef.current);
|
|
1931
1929
|
root.selectAll('*').remove();
|
|
1932
|
-
const svg = initializeSvg({
|
|
1930
|
+
const svg = initializeSvg$1({
|
|
1933
1931
|
svgRef,
|
|
1934
1932
|
width,
|
|
1935
1933
|
height,
|
|
@@ -1947,7 +1945,7 @@ const WindBarbs = ({
|
|
|
1947
1945
|
widthRef.current = width;
|
|
1948
1946
|
const minT = diagramType === 'EMAGRAM' ? minEmagramTemperature : minTemperature;
|
|
1949
1947
|
const maxT = maxTemperature;
|
|
1950
|
-
const [xScale, yScale] = createScales(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1948
|
+
const [xScale, yScale] = createScales$1(diagramType, width, height, minPressure, maxPressure, minT, maxT);
|
|
1951
1949
|
yScaleRef.current = yScale;
|
|
1952
1950
|
drawIsobars({
|
|
1953
1951
|
diagramType,
|
|
@@ -2040,6 +2038,257 @@ const WindBarbs = ({
|
|
|
2040
2038
|
});
|
|
2041
2039
|
};
|
|
2042
2040
|
|
|
2041
|
+
/* *
|
|
2042
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
2043
|
+
* you may not use this file except in compliance with the License.
|
|
2044
|
+
* You may obtain a copy of the License at
|
|
2045
|
+
*
|
|
2046
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
2047
|
+
*
|
|
2048
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
2049
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
2050
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
2051
|
+
* See the License for the specific language governing permissions and
|
|
2052
|
+
* limitations under the License.
|
|
2053
|
+
*
|
|
2054
|
+
* Copyright 2026 - Koninklijk Nederlands Meteorologisch Instituut (KNMI)
|
|
2055
|
+
* Copyright 2026 - Finnish Meteorological Institute (FMI)
|
|
2056
|
+
* Copyright 2026 - The Norwegian Meteorological Institute (MET Norway)
|
|
2057
|
+
* */
|
|
2058
|
+
const initializeSvg = ({
|
|
2059
|
+
svgRef,
|
|
2060
|
+
widthHeight
|
|
2061
|
+
}) => {
|
|
2062
|
+
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;');
|
|
2063
|
+
const gSel = svg.select('g.hodograph');
|
|
2064
|
+
if (gSel.empty()) {
|
|
2065
|
+
svg.append('g').attr('class', 'hodograph');
|
|
2066
|
+
}
|
|
2067
|
+
return svg;
|
|
2068
|
+
};
|
|
2069
|
+
const createScales = widthHeight => {
|
|
2070
|
+
const scaleRadius = d3.scaleLinear().domain([0, 100]).range([0, widthHeight / 2]);
|
|
2071
|
+
const scaleAngle = d3.scaleLinear().domain([0, 360]).range([0, 2 * Math.PI]);
|
|
2072
|
+
return [scaleRadius, scaleAngle];
|
|
2073
|
+
};
|
|
2074
|
+
const drawAxes = ({
|
|
2075
|
+
svg,
|
|
2076
|
+
scaleRadius,
|
|
2077
|
+
scaleAngle,
|
|
2078
|
+
widthHeight
|
|
2079
|
+
}) => {
|
|
2080
|
+
// Draw circles
|
|
2081
|
+
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)));
|
|
2082
|
+
// Draw windspeed scale
|
|
2083
|
+
const speedTicks = scaleRadius.ticks(9);
|
|
2084
|
+
svg.select('g').append('g').attr('type', 'windspeed').attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`) // origin transformed to center
|
|
2085
|
+
.call(d3.axisBottom(scaleRadius).tickValues(speedTicks).tickSizeInner(0).tickSizeOuter(0))
|
|
2086
|
+
// Draw unit label
|
|
2087
|
+
.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');
|
|
2088
|
+
};
|
|
2089
|
+
// Use this function to filter the pressure and parameter array so no NaN or null values are included
|
|
2090
|
+
const filterDomainRangeForHodograph = (pressureDomain, parameterDomain) => {
|
|
2091
|
+
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);
|
|
2092
|
+
const filteredParameterDomain = parameterDomain.filter(value => !isNaN(value[0]) && value[0] !== null && !isNaN(value[1]) && value[1] !== null);
|
|
2093
|
+
// If first pressure value is smallest, reverse arrays to ensure we draw starting from surface
|
|
2094
|
+
if (filteredPressureDomain[0] < filteredPressureDomain[filteredPressureDomain.length - 1]) {
|
|
2095
|
+
return [filteredPressureDomain.reverse(), filteredParameterDomain.reverse()];
|
|
2096
|
+
}
|
|
2097
|
+
return [filteredPressureDomain, filteredParameterDomain];
|
|
2098
|
+
};
|
|
2099
|
+
const T0_SEA = 288.15; // Standard temperature in Kelvin (15 degrees Celsius)
|
|
2100
|
+
const P0_SEA = 101325; // Reference pressure (Pa)
|
|
2101
|
+
const R_GAS = 8.314462618; // Universal gas constant (J/mol K)
|
|
2102
|
+
const L_TEMP = 0.0065; // Temperature gradient (K/m) at sea level
|
|
2103
|
+
const G_ACC = 9.80665; // The gravitational acceleration in units of geopotential height (m/s2)
|
|
2104
|
+
const M_AIR = 0.0289647; // Mean molar mass of air at sea level (kg/mol)
|
|
2105
|
+
const exponent = R_GAS * L_TEMP / (G_ACC * M_AIR); // ≈ 0.1903
|
|
2106
|
+
// This function returns the altitude above MSL using the barometric formula in m
|
|
2107
|
+
// Pressure passed in is in hPa, which is converted to Pa (pressure * 100)
|
|
2108
|
+
const altitudeBarometricFromPressure = pressure => {
|
|
2109
|
+
const altitude = T0_SEA / L_TEMP * ((P0_SEA / (pressure * 100)) ** exponent - 1);
|
|
2110
|
+
return altitude;
|
|
2111
|
+
};
|
|
2112
|
+
const generateSegmentsBasedOnLevel = (pressure, windValues) => {
|
|
2113
|
+
const segments = [[], [], [], [], []];
|
|
2114
|
+
pressure.forEach((pressureVal, index) => {
|
|
2115
|
+
// Ensure we ignore the last value
|
|
2116
|
+
if (pressure[index + 1]) {
|
|
2117
|
+
const heightInMeters = altitudeBarometricFromPressure(pressureVal);
|
|
2118
|
+
// index 0, red line
|
|
2119
|
+
if (heightInMeters < 1000) {
|
|
2120
|
+
segments[0].push(windValues[index]);
|
|
2121
|
+
segments[0].push(windValues[index + 1]);
|
|
2122
|
+
}
|
|
2123
|
+
// index 1, green line
|
|
2124
|
+
if (heightInMeters >= 1000 && heightInMeters < 3000) {
|
|
2125
|
+
segments[1].push(windValues[index]);
|
|
2126
|
+
segments[1].push(windValues[index + 1]);
|
|
2127
|
+
}
|
|
2128
|
+
// index 2, yellow line
|
|
2129
|
+
if (heightInMeters >= 3000 && heightInMeters < 6000) {
|
|
2130
|
+
segments[2].push(windValues[index]);
|
|
2131
|
+
segments[2].push(windValues[index + 1]);
|
|
2132
|
+
}
|
|
2133
|
+
// index 3, purple line
|
|
2134
|
+
if (heightInMeters >= 6000 && heightInMeters < 9000) {
|
|
2135
|
+
segments[3].push(windValues[index]);
|
|
2136
|
+
segments[3].push(windValues[index + 1]);
|
|
2137
|
+
}
|
|
2138
|
+
// index 4, black line
|
|
2139
|
+
if (heightInMeters >= 9000 && heightInMeters <= 19000) {
|
|
2140
|
+
segments[4].push(windValues[index]);
|
|
2141
|
+
segments[4].push(windValues[index + 1]);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
return segments;
|
|
2146
|
+
};
|
|
2147
|
+
const HODOGRAPH_COLORS = ['red', 'olivedrab', 'orange', 'darkviolet', 'black'];
|
|
2148
|
+
const getColorForSegment = index => {
|
|
2149
|
+
if (HODOGRAPH_COLORS[index]) {
|
|
2150
|
+
return HODOGRAPH_COLORS[index];
|
|
2151
|
+
}
|
|
2152
|
+
return 'black';
|
|
2153
|
+
};
|
|
2154
|
+
const plotHodograph = ({
|
|
2155
|
+
windValues,
|
|
2156
|
+
pressureValues,
|
|
2157
|
+
scaleAngle,
|
|
2158
|
+
scaleRadius,
|
|
2159
|
+
widthHeight,
|
|
2160
|
+
svg,
|
|
2161
|
+
index
|
|
2162
|
+
}) => {
|
|
2163
|
+
// Filter out any NaN or null values
|
|
2164
|
+
const [filteredPressureValues, filteredWindValues] = filterDomainRangeForHodograph(pressureValues, windValues);
|
|
2165
|
+
// Construct path segments to be able to give each segment it's own color
|
|
2166
|
+
const pathSegments = generateSegmentsBasedOnLevel(filteredPressureValues, filteredWindValues);
|
|
2167
|
+
// index 0 = wind direction, index 1 = wind speed
|
|
2168
|
+
const lineGenerator = d3.lineRadial().angle(dataPoint => scaleAngle(dataPoint[0])).radius(dataPoint => scaleRadius(dataPoint[1]));
|
|
2169
|
+
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));
|
|
2170
|
+
// Adding a dot for the very first data point in the color used in the soundings graph
|
|
2171
|
+
const firstDataPoint = filteredWindValues[0];
|
|
2172
|
+
if (firstDataPoint) {
|
|
2173
|
+
const color = SOUNDING_COLORS[index] || SOUNDING_COLORS[0];
|
|
2174
|
+
svg.select('g.hodograph').append('g').attr('transform', `translate(${widthHeight / 2}, ${widthHeight / 2})`) // origin transformed to center
|
|
2175
|
+
.attr('fill', color).attr('stroke', 'none').attr('data-testid', `hodograph-datastart-${index}`).selectAll().data([firstDataPoint]).join('circle').attr('cx', dataPoint => {
|
|
2176
|
+
const angle = scaleAngle(dataPoint[0]);
|
|
2177
|
+
const radius = scaleRadius(dataPoint[1]);
|
|
2178
|
+
return radius * Math.sin(angle);
|
|
2179
|
+
}).attr('cy', dataPoint => {
|
|
2180
|
+
const angle = scaleAngle(dataPoint[0]);
|
|
2181
|
+
const radius = scaleRadius(dataPoint[1]);
|
|
2182
|
+
return radius * -1 * Math.cos(angle);
|
|
2183
|
+
}).attr('r', 3);
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
|
|
2187
|
+
const Hodograph = ({
|
|
2188
|
+
data
|
|
2189
|
+
}) => {
|
|
2190
|
+
const svgRef = useRef(null);
|
|
2191
|
+
const widthHeight = 400;
|
|
2192
|
+
useEffect(() => {
|
|
2193
|
+
if (!svgRef.current) {
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
// Define svg
|
|
2197
|
+
const svg = initializeSvg({
|
|
2198
|
+
svgRef,
|
|
2199
|
+
widthHeight
|
|
2200
|
+
});
|
|
2201
|
+
// Set scales for temperature and pressure
|
|
2202
|
+
const [scaleRadius, scaleAngle] = createScales(widthHeight);
|
|
2203
|
+
// Add axis'
|
|
2204
|
+
drawAxes({
|
|
2205
|
+
svg,
|
|
2206
|
+
scaleRadius,
|
|
2207
|
+
scaleAngle,
|
|
2208
|
+
widthHeight
|
|
2209
|
+
});
|
|
2210
|
+
data.forEach((features, index) => {
|
|
2211
|
+
const windValues = features?.map(feature => {
|
|
2212
|
+
// Transform windDirectionFrom to windDirectionTo
|
|
2213
|
+
const windDirectionTo = ((feature.properties.windDirection + 180) % 360 + 360) % 360;
|
|
2214
|
+
return [windDirectionTo, feature.properties.windSpeed];
|
|
2215
|
+
});
|
|
2216
|
+
const pressureValues = features.map(feature => feature.properties.pressure);
|
|
2217
|
+
// Draw Temperature
|
|
2218
|
+
plotHodograph({
|
|
2219
|
+
windValues,
|
|
2220
|
+
pressureValues,
|
|
2221
|
+
scaleAngle,
|
|
2222
|
+
scaleRadius,
|
|
2223
|
+
widthHeight,
|
|
2224
|
+
svg,
|
|
2225
|
+
index
|
|
2226
|
+
});
|
|
2227
|
+
});
|
|
2228
|
+
// Allow zooming and panning
|
|
2229
|
+
const zoomBehavior = d3.zoom().scaleExtent([1, 7]).translateExtent([[-0.2 * widthHeight, -0.2 * widthHeight], [1.2 * widthHeight, 1.2 * widthHeight]]).on('zoom', event => {
|
|
2230
|
+
svg.select('g.hodograph').attr('transform', event.transform);
|
|
2231
|
+
});
|
|
2232
|
+
svg.call(zoomBehavior);
|
|
2233
|
+
// eslint-disable-next-line consistent-return
|
|
2234
|
+
return () => {
|
|
2235
|
+
svg.on('zoom', null);
|
|
2236
|
+
svg.select('g.hodograph').selectAll('*').remove();
|
|
2237
|
+
};
|
|
2238
|
+
}, [data, widthHeight]);
|
|
2239
|
+
return jsx("svg", {
|
|
2240
|
+
height: "100%",
|
|
2241
|
+
ref: svgRef,
|
|
2242
|
+
width: "100%"
|
|
2243
|
+
});
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
const getLegendItem = (color, text) => {
|
|
2247
|
+
return jsxs(Typography, {
|
|
2248
|
+
sx: {
|
|
2249
|
+
color: 'geowebColors.typographyAndIcons.text',
|
|
2250
|
+
fontSize: '9px'
|
|
2251
|
+
},
|
|
2252
|
+
variant: "caption",
|
|
2253
|
+
children: [jsx(Box, {
|
|
2254
|
+
sx: {
|
|
2255
|
+
width: '16px',
|
|
2256
|
+
height: '4px',
|
|
2257
|
+
backgroundColor: color,
|
|
2258
|
+
display: 'inline-block',
|
|
2259
|
+
marginRight: '4px',
|
|
2260
|
+
marginBottom: '2px'
|
|
2261
|
+
}
|
|
2262
|
+
}), text]
|
|
2263
|
+
});
|
|
2264
|
+
};
|
|
2265
|
+
const HodographLegend = () => {
|
|
2266
|
+
return jsxs(Stack, {
|
|
2267
|
+
direction: "column",
|
|
2268
|
+
justifyContent: "center",
|
|
2269
|
+
sx: {
|
|
2270
|
+
flexWrap: 'wrap',
|
|
2271
|
+
paddingLeft: 2,
|
|
2272
|
+
paddingTop: 0.5
|
|
2273
|
+
},
|
|
2274
|
+
useFlexGap: true,
|
|
2275
|
+
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')]
|
|
2276
|
+
});
|
|
2277
|
+
};
|
|
2278
|
+
|
|
2279
|
+
const HodographCollection = ({
|
|
2280
|
+
data
|
|
2281
|
+
}) => {
|
|
2282
|
+
return jsxs(Box, {
|
|
2283
|
+
sx: {
|
|
2284
|
+
padding: '5px'
|
|
2285
|
+
},
|
|
2286
|
+
children: [jsx(Hodograph, {
|
|
2287
|
+
data: data
|
|
2288
|
+
}), jsx(HodographLegend, {})]
|
|
2289
|
+
});
|
|
2290
|
+
};
|
|
2291
|
+
|
|
2043
2292
|
const ACCORDION_MIN_WIDTH = 24;
|
|
2044
2293
|
const SoundingsAccordion = React.forwardRef(({
|
|
2045
2294
|
name,
|
|
@@ -2353,6 +2602,14 @@ const SortableAccordions = ({
|
|
|
2353
2602
|
}
|
|
2354
2603
|
})
|
|
2355
2604
|
});
|
|
2605
|
+
case 'hodograph':
|
|
2606
|
+
return jsx(SoundingsAccordion, {
|
|
2607
|
+
maxWidth: 500,
|
|
2608
|
+
name: "Hodograph",
|
|
2609
|
+
children: jsx(HodographCollection, {
|
|
2610
|
+
data: data
|
|
2611
|
+
})
|
|
2612
|
+
});
|
|
2356
2613
|
default:
|
|
2357
2614
|
return null;
|
|
2358
2615
|
}
|
|
@@ -2558,6 +2815,7 @@ const aConstant = 17.67;
|
|
|
2558
2815
|
const bConstant = 243.5; // Constants for water, b in Celsius
|
|
2559
2816
|
const gConstant = 461.5; // Gas constant ratio between water vapour and air [J/(K*kg)]
|
|
2560
2817
|
const hConstant = 2.5e6; // Latent heat for water vaporization [J/kg]
|
|
2818
|
+
const ktsConstant = 1.94384;
|
|
2561
2819
|
// Formula to use when dewpoint is not available
|
|
2562
2820
|
// Known as Magnus Formula
|
|
2563
2821
|
// - Calculate vapor pressure
|
|
@@ -2582,8 +2840,15 @@ relHumidity) {
|
|
|
2582
2840
|
const dewpoint = tKelvin / (1 - tKelvin * Math.log(relHumidity * 0.01) * gConstant / hConstant);
|
|
2583
2841
|
return dewpoint;
|
|
2584
2842
|
}
|
|
2843
|
+
const getWindSpeedInKts = (windSpeed, windSpeedUnit) => {
|
|
2844
|
+
if (windSpeedUnit === 'kts') {
|
|
2845
|
+
return windSpeed;
|
|
2846
|
+
}
|
|
2847
|
+
// Formula to convert from m/s to kts
|
|
2848
|
+
return windSpeed * ktsConstant;
|
|
2849
|
+
};
|
|
2585
2850
|
// Parsing and conversion function
|
|
2586
|
-
function parseCoverageCollection(response, parameterMappings) {
|
|
2851
|
+
function parseCoverageCollection(response, parameterMappings, windSpeedUnit) {
|
|
2587
2852
|
const features = [];
|
|
2588
2853
|
const pressureArray = [];
|
|
2589
2854
|
const tempArray = [];
|
|
@@ -2687,13 +2952,14 @@ function parseCoverageCollection(response, parameterMappings) {
|
|
|
2687
2952
|
dewpoint = dewpointFromRelativeHumidity(tempArray[index], relativeHumidity);
|
|
2688
2953
|
}
|
|
2689
2954
|
// Extract wind speed and direction - use fallback calculations if no speed and direction in parameter mappings
|
|
2955
|
+
// Convert to kts if retrieved in m/s
|
|
2690
2956
|
let windSpeed = NaN;
|
|
2691
2957
|
if (parameterMappings.windSpeed) {
|
|
2692
|
-
windSpeed = collectionData.windSpeedArray[index];
|
|
2958
|
+
windSpeed = getWindSpeedInKts(collectionData.windSpeedArray[index], windSpeedUnit);
|
|
2693
2959
|
} else if (parameterMappings.windU && parameterMappings.windV) {
|
|
2694
2960
|
const windU = collectionData.windUArray[index];
|
|
2695
2961
|
const windV = collectionData.windVArray[index];
|
|
2696
|
-
windSpeed = Math.sqrt(windU ** 2 + windV ** 2);
|
|
2962
|
+
windSpeed = getWindSpeedInKts(Math.sqrt(windU ** 2 + windV ** 2), windSpeedUnit);
|
|
2697
2963
|
}
|
|
2698
2964
|
let windDirection = NaN;
|
|
2699
2965
|
if (parameterMappings.windDirection) {
|
|
@@ -2802,7 +3068,7 @@ const sortSoundingFeatures = data => {
|
|
|
2802
3068
|
return featureB.properties.pressure - featureA.properties.pressure;
|
|
2803
3069
|
});
|
|
2804
3070
|
};
|
|
2805
|
-
const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, verticalRange) => {
|
|
3071
|
+
const constructQueryParams = (collectionId, baseUrl, instanceId, location, datetime, parameterMappings, windSpeedUnit, verticalRange) => {
|
|
2806
3072
|
const parameters = getParameterMappings(parameterMappings);
|
|
2807
3073
|
if (typeof location === 'string') {
|
|
2808
3074
|
// LOCATION call
|
|
@@ -2815,7 +3081,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
|
|
|
2815
3081
|
}
|
|
2816
3082
|
try {
|
|
2817
3083
|
const rawData = await getEdrCollectionForLocation(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
|
|
2818
|
-
const parsedData = parseCoverageCollection(rawData, parameterMappings);
|
|
3084
|
+
const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
|
|
2819
3085
|
return sortSoundingFeatures(parsedData);
|
|
2820
3086
|
} catch (_err) {
|
|
2821
3087
|
throw new Error('Error fetching EDR data collection by location');
|
|
@@ -2833,7 +3099,7 @@ const constructQueryParams = (collectionId, baseUrl, instanceId, location, datet
|
|
|
2833
3099
|
}
|
|
2834
3100
|
try {
|
|
2835
3101
|
const rawData = await getEdrCollectionForPosition(collectionId, baseUrl, instanceId, location, parameters, datetime, verticalRange);
|
|
2836
|
-
const parsedData = parseCoverageCollection(rawData, parameterMappings);
|
|
3102
|
+
const parsedData = parseCoverageCollection(rawData, parameterMappings, windSpeedUnit);
|
|
2837
3103
|
return sortSoundingFeatures(parsedData);
|
|
2838
3104
|
} catch (_err) {
|
|
2839
3105
|
throw new Error('Error fetching EDR data collection by position');
|
|
@@ -2868,6 +3134,7 @@ const useMultipleSources = (sources, locations, datetime) => {
|
|
|
2868
3134
|
url,
|
|
2869
3135
|
instanceId,
|
|
2870
3136
|
parameterMappings,
|
|
3137
|
+
windSpeedUnit,
|
|
2871
3138
|
verticalRange
|
|
2872
3139
|
}, index) => {
|
|
2873
3140
|
// Find the best matching timestep for this source
|
|
@@ -2892,7 +3159,7 @@ const useMultipleSources = (sources, locations, datetime) => {
|
|
|
2892
3159
|
dateToUse = nearestTimestep;
|
|
2893
3160
|
}
|
|
2894
3161
|
}
|
|
2895
|
-
const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings, verticalRange || '');
|
|
3162
|
+
const queryParams = constructQueryParams(collectionId, url, instanceId, locations[collectionId] || '', dateToUse || '', parameterMappings || defaultParameterMappings, windSpeedUnit || 'ms', verticalRange || '');
|
|
2896
3163
|
return queryParams;
|
|
2897
3164
|
});
|
|
2898
3165
|
}, [datetime, locations, sources, timesteps]);
|
|
@@ -3138,7 +3405,7 @@ const initialSoundingsState = {
|
|
|
3138
3405
|
selectedTime: new Date().toISOString(),
|
|
3139
3406
|
diagramType: 'SKEWT',
|
|
3140
3407
|
soundingPanels: {
|
|
3141
|
-
order: ['soundings', 'windbarbs'],
|
|
3408
|
+
order: ['soundings', 'windbarbs', 'hodograph'],
|
|
3142
3409
|
entities: {
|
|
3143
3410
|
soundings: {
|
|
3144
3411
|
id: 'soundings',
|
|
@@ -3149,6 +3416,11 @@ const initialSoundingsState = {
|
|
|
3149
3416
|
id: 'windbarbs',
|
|
3150
3417
|
name: 'Wind Barbs',
|
|
3151
3418
|
isDisplayed: true
|
|
3419
|
+
},
|
|
3420
|
+
hodograph: {
|
|
3421
|
+
id: 'hodograph',
|
|
3422
|
+
name: 'Hodograph',
|
|
3423
|
+
isDisplayed: true
|
|
3152
3424
|
}
|
|
3153
3425
|
}
|
|
3154
3426
|
},
|
|
@@ -3349,6 +3621,7 @@ const getSelectedSources = createSelector(getSoundingsStore, store => {
|
|
|
3349
3621
|
instanceId: value.instanceId,
|
|
3350
3622
|
verticalRange: value.verticalRange,
|
|
3351
3623
|
parameterMappings: value.parameterMappings,
|
|
3624
|
+
windSpeedUnit: value.windSpeedUnit ?? 'ms',
|
|
3352
3625
|
name: value.name ?? ''
|
|
3353
3626
|
});
|
|
3354
3627
|
}
|
|
@@ -4088,7 +4361,8 @@ const SoundingsConnect = ({
|
|
|
4088
4361
|
const orderedIds = newOrder.map(collection => collection.id);
|
|
4089
4362
|
dispatch(soundingsActions.changeSoundingPanelOrder({
|
|
4090
4363
|
panelId: viewId,
|
|
4091
|
-
newOrderOfIds: orderedIds
|
|
4364
|
+
newOrderOfIds: orderedIds,
|
|
4365
|
+
origin: SoundingsActionOrigin.soundingsComponent
|
|
4092
4366
|
}));
|
|
4093
4367
|
};
|
|
4094
4368
|
return jsxs(Fragment, {
|
|
@@ -4360,6 +4634,7 @@ const SoundingsSourceDialogConnect = ({
|
|
|
4360
4634
|
url: collection.url,
|
|
4361
4635
|
isChecked: currentSelection?.isChecked || false,
|
|
4362
4636
|
parameterMappings: collection.parameterMappings,
|
|
4637
|
+
windSpeedUnit: collection.windSpeedUnit,
|
|
4363
4638
|
...(collection.name && collection.name !== '' ? {
|
|
4364
4639
|
name: collection.name
|
|
4365
4640
|
} : {})
|
|
@@ -4589,28 +4864,6 @@ const LocationsMenuConnect = ({
|
|
|
4589
4864
|
});
|
|
4590
4865
|
};
|
|
4591
4866
|
|
|
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
4867
|
const SoundingManagerListItem = ({
|
|
4615
4868
|
id,
|
|
4616
4869
|
name,
|
|
@@ -4723,24 +4976,20 @@ const SoundingsManagerConnect = () => {
|
|
|
4723
4976
|
} = useSetupDialog(uiTypes.DialogTypes.SoundingsManager);
|
|
4724
4977
|
const dispatch = useDispatch();
|
|
4725
4978
|
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
4979
|
const soundingPanelsInOrder = useSelector(store => getSoundingPanelsInOrder(store, panelId));
|
|
4734
4980
|
const changeOrder = newOrder => {
|
|
4735
4981
|
const orderedIds = newOrder.map(collection => collection.id);
|
|
4736
4982
|
dispatch(soundingsActions.changeSoundingPanelOrder({
|
|
4737
4983
|
panelId,
|
|
4738
|
-
newOrderOfIds: orderedIds
|
|
4984
|
+
newOrderOfIds: orderedIds,
|
|
4985
|
+
origin: 'soundingsManager'
|
|
4739
4986
|
}));
|
|
4740
4987
|
};
|
|
4741
4988
|
const toggleVisibility = id => {
|
|
4742
4989
|
dispatch(soundingsActions.toggleSoundingPanelVisibility({
|
|
4743
|
-
id
|
|
4990
|
+
id,
|
|
4991
|
+
panelId,
|
|
4992
|
+
origin: 'soundingsManager'
|
|
4744
4993
|
}));
|
|
4745
4994
|
};
|
|
4746
4995
|
return jsx(SoundingsManager, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeoweb/soundings",
|
|
3
|
-
"version": "15.
|
|
3
|
+
"version": "15.3.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.
|
|
11
|
+
"@opengeoweb/api": "15.3.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.
|
|
17
|
+
"@opengeoweb/shared": "15.3.0",
|
|
18
18
|
"@mui/material": "^7.0.1",
|
|
19
|
-
"@opengeoweb/store": "15.
|
|
19
|
+
"@opengeoweb/store": "15.3.0",
|
|
20
20
|
"react-redux": "^9.2.0",
|
|
21
|
-
"@opengeoweb/theme": "15.
|
|
21
|
+
"@opengeoweb/theme": "15.3.0",
|
|
22
22
|
"@reduxjs/toolkit": "^2.6.1",
|
|
23
|
-
"@opengeoweb/time-slider": "15.
|
|
23
|
+
"@opengeoweb/time-slider": "15.3.0",
|
|
24
24
|
"react-window": "^2.2.6",
|
|
25
|
-
"@opengeoweb/webmap": "15.
|
|
25
|
+
"@opengeoweb/webmap": "15.3.0",
|
|
26
26
|
"lodash": "^4.17.21",
|
|
27
27
|
"@types/geojson": "^7946.0.14",
|
|
28
|
-
"@opengeoweb/webmap-react": "15.
|
|
28
|
+
"@opengeoweb/webmap-react": "15.3.0",
|
|
29
29
|
"react-resizable-layout": "^0.7.3",
|
|
30
30
|
"react-sortablejs": "^6.1.4"
|
|
31
31
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
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 function parseCoverageCollection(response: CoverageCollection<VerticalProfileDomain> | Coverage<VerticalProfileDomain>, parameterMappings: Record<string, string>, windSpeedUnit: string): SoundingFeature[];
|
|
4
5
|
/**
|
|
5
6
|
* Parse duration with repeats
|
|
6
7
|
* @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 {};
|