diff --git a/windwatts-ui/public/gtm-init.js b/windwatts-ui/public/gtm-init.js index 29986aa..6b518af 100644 --- a/windwatts-ui/public/gtm-init.js +++ b/windwatts-ui/public/gtm-init.js @@ -1,10 +1,10 @@ (function (w, d, s, l, i) { w[l] = w[l] || []; - w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); + w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" }); var f = d.getElementsByTagName(s)[0], j = d.createElement(s), - dl = l != 'dataLayer' ? '&l=' + l : ''; + dl = l != "dataLayer" ? "&l=" + l : ""; j.async = true; - j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; + j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl; f.parentNode.insertBefore(j, f); -})(window, document, 'script', 'dataLayer', 'GTM-KG7PVKB'); +})(window, document, "script", "dataLayer", "GTM-KG7PVKB"); diff --git a/windwatts-ui/src/components/settings/HubHeightSettings.tsx b/windwatts-ui/src/components/settings/HubHeightSettings.tsx index 9987844..173a33d 100644 --- a/windwatts-ui/src/components/settings/HubHeightSettings.tsx +++ b/windwatts-ui/src/components/settings/HubHeightSettings.tsx @@ -1,15 +1,16 @@ -import { useContext, useEffect, useMemo } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { SettingsContext } from "../../providers/SettingsContext"; import { Box, + IconButton, + InputAdornment, Slider, - Typography, - Paper, + TextField, Tooltip, - IconButton, + Typography, } from "@mui/material"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import { resolveCustomCurve } from "../../utils"; +import { resolveCustomCurve, resolveHubHeight } from "../../utils"; import { HUB_HEIGHTS, TURBINE_DATA } from "../../constants"; export function HubHeightSettings() { @@ -21,48 +22,78 @@ export function HubHeightSettings() { customCurves, } = useContext(SettingsContext); - const { values: availableHeights, interpolation: step } = useMemo(() => { - if (dataModel && HUB_HEIGHTS[dataModel]) { - return HUB_HEIGHTS[dataModel]; - } - return HUB_HEIGHTS.default; - }, [dataModel]); + const { values: availableHeights, interpolation: interpolable } = + useMemo(() => { + if (dataModel && HUB_HEIGHTS[dataModel]) { + return HUB_HEIGHTS[dataModel]; + } + return HUB_HEIGHTS.default; + }, [dataModel]); + + const modelMin = Math.min(...availableHeights); + const modelMax = Math.max(...availableHeights); + + const [inputValue, setInputValue] = useState(String(hubHeight)); - // ensure slider compatibility with model switching and available heights changes + // keep text field in sync when hubHeight changes externally (slider, model switch) useEffect(() => { - if (!availableHeights.includes(hubHeight)) { - // if current height not available, set to the closest available height - const closestHeight = availableHeights.reduce((prev, curr) => - Math.abs(curr - hubHeight) < Math.abs(prev - hubHeight) ? curr : prev - ); - setHubHeight(closestHeight); - } - }, [availableHeights, hubHeight, setHubHeight]); + setInputValue(String(hubHeight)); + }, [hubHeight]); + + // clamp or snap on model switch / available heights change + useEffect(() => { + const resolved = resolveHubHeight( + hubHeight, + availableHeights, + interpolable + ); + if (resolved !== hubHeight) setHubHeight(resolved); + }, [availableHeights, interpolable, hubHeight, setHubHeight]); const hubHeightMarks = availableHeights.map((value: number) => ({ - value: value, + value, label: `${value}m`, })); - const handleHubHeightChange = ( - _: Event, - newHubHeight: number | number[] | null - ) => { - if (newHubHeight !== null && typeof newHubHeight === "number") { - setHubHeight(newHubHeight); + const handleSliderChange = (_: Event, newValue: number | number[]) => { + if (typeof newValue === "number") { + setHubHeight(newValue); } }; + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const commitInput = () => { + const parsed = parseInt(inputValue, 10); + if (!isNaN(parsed)) { + const resolvedHubHeight = resolveHubHeight( + parsed, + availableHeights, + interpolable + ); + setHubHeight(resolvedHubHeight); + } else { + setInputValue(String(hubHeight)); + } + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") commitInput(); + }; + const customCurve = resolveCustomCurve(turbine, customCurves); const turbineInfo = TURBINE_DATA[turbine]; - const minHeight = customCurve?.minHeight ?? turbineInfo?.minHeight; - const maxHeight = customCurve?.maxHeight ?? turbineInfo?.maxHeight; - const hasHeightRange = minHeight !== undefined && maxHeight !== undefined; + const turbineMinHeight = customCurve?.minHeight ?? turbineInfo?.minHeight; + const turbineMaxHeight = customCurve?.maxHeight ?? turbineInfo?.maxHeight; + const hasHeightRange = + turbineMinHeight !== undefined && turbineMaxHeight !== undefined; const heightRangeInfo = turbineInfo?.info ?? ""; const isHeightInRange: boolean = hasHeightRange - ? hubHeight >= minHeight! && hubHeight <= maxHeight! + ? hubHeight >= turbineMinHeight! && hubHeight <= turbineMaxHeight! : true; const validationColor: "primary" | "success" | "warning" = hasHeightRange @@ -76,50 +107,76 @@ export function HubHeightSettings() { Hub Height - - Choose nearest hub height (m): - - {hasHeightRange && ( - + Set hub height: + m, + }, + htmlInput: { + min: modelMin, + max: modelMax, + step: 1, + "aria-label": "hub height input", + }, }} - > - - - - {isHeightInRange ? "Within" : "Outside"} recommended range - ( - {minHeight}m - {maxHeight}m) - - - {heightRangeInfo && ( - - - - - - )} - - + sx={{ width: 100 }} + /> + + + + `${value}m`} + step={interpolable ? 1 : null} + marks={hubHeightMarks} + min={modelMin} + max={modelMax} + color={validationColor} + /> + + + {interpolable && ( + + * Values between marks are interpolated (no extrapolation). + )} - `${value}m`} - step={step} - marks={hubHeightMarks} - min={Math.min(...availableHeights)} - max={Math.max(...availableHeights)} - color={validationColor} - /> + {hasHeightRange && ( + + + * Recommended range: {turbineMinHeight}m - {turbineMaxHeight}m + + {heightRangeInfo && ( + + + + + + )} + + )} ); } diff --git a/windwatts-ui/src/components/settings/LossAssumptionSettings.tsx b/windwatts-ui/src/components/settings/LossAssumptionSettings.tsx index 8a04cc2..9d34bf6 100644 --- a/windwatts-ui/src/components/settings/LossAssumptionSettings.tsx +++ b/windwatts-ui/src/components/settings/LossAssumptionSettings.tsx @@ -44,7 +44,7 @@ export function LossAssumptionSettings() { Loss Assumption - Set an energy percent loss (17% recommended). + Set an energy percent loss (17% recommended): = { - "era5-quantiles": { values: [30, 40, 50, 60, 80, 100], interpolation: null }, - "wtk-timeseries": { values: [40, 60, 80, 100, 120, 140], interpolation: 10 }, + "era5-quantiles": { values: [30, 40, 50, 60, 80, 100], interpolation: true }, + "wtk-timeseries": { + values: [40, 60, 80, 100, 120, 140], + interpolation: true, + }, "ensemble-quantiles": { values: [30, 40, 50, 60, 80, 100], - interpolation: null, + interpolation: true, }, - "era5-timeseries": { values: [30, 40, 50, 60, 80, 100], interpolation: null }, - default: { values: [40, 60, 80, 100], interpolation: null }, + "era5-timeseries": { values: [30, 40, 50, 60, 80, 100], interpolation: true }, + default: { values: [40, 60, 80, 100], interpolation: false }, }; diff --git a/windwatts-ui/src/types/Heights.ts b/windwatts-ui/src/types/Heights.ts index 449c2cc..5652cb0 100644 --- a/windwatts-ui/src/types/Heights.ts +++ b/windwatts-ui/src/types/Heights.ts @@ -1,4 +1,4 @@ export interface Heights { values: number[]; - interpolation: number | null; + interpolation: boolean; } diff --git a/windwatts-ui/src/utils/turbine.ts b/windwatts-ui/src/utils/turbine.ts index f643f96..ba6adfa 100644 --- a/windwatts-ui/src/utils/turbine.ts +++ b/windwatts-ui/src/utils/turbine.ts @@ -51,6 +51,25 @@ export const parseOptionalHeight = (value: string): number | undefined => { return trimmed ? Number(trimmed) : undefined; }; +/** + * Resolves a raw hub height value to a valid value for the given model. + * - Interpolable model: clamps to [min, max] — no extrapolation. + * - Non-interpolable model: clamps then snaps to the nearest available height. + */ +export const resolveHubHeight = ( + value: number, + availableHeights: number[], + interpolable: boolean +): number => { + const min = Math.min(...availableHeights); + const max = Math.max(...availableHeights); + const clamped = Math.max(min, Math.min(max, value)); + if (interpolable) return clamped; + return availableHeights.reduce((prev, curr) => + Math.abs(curr - clamped) < Math.abs(prev - clamped) ? curr : prev + ); +}; + /** * Validates an optional hub height range. * Returns an error message string, or null when the values are valid.