diff --git a/src/features/outlook/Day.tsx b/src/features/outlook/Day.tsx new file mode 100644 index 0000000..dc24155 --- /dev/null +++ b/src/features/outlook/Day.tsx @@ -0,0 +1,50 @@ +import styled from "@emotion/styled"; +import { formatInTimeZone } from "date-fns-tz"; +import { useAppSelector } from "../../hooks"; +import { timeZoneSelector } from "../weather/weatherSlice"; + +const Table = styled.table` + width: 100%; + + padding: 0; + border-collapse: collapse; + border: none; + + text-align: center; +`; + +const THead = styled.thead` + position: sticky; + top: 0; + transform: translateY(-0.5px); + z-index: 1; + background: var(--bg-bottom-sheet); +`; + +const DayLabelCell = styled.th` + text-align: start; + padding: 8px 16px; +`; + +interface DayProps { + date: Date; + hours: React.ReactNode[]; +} + +export default function Day({ hours, date }: DayProps) { + const timeZone = useAppSelector(timeZoneSelector); + if (!timeZone) throw new Error("timeZone needed"); + + return ( + + + + + {formatInTimeZone(date, timeZone, "eeee, LLL d")} + + + + {hours} +
+ ); +} diff --git a/src/features/outlook/Outlook.tsx b/src/features/outlook/Outlook.tsx new file mode 100644 index 0000000..f759a2e --- /dev/null +++ b/src/features/outlook/Outlook.tsx @@ -0,0 +1,11 @@ +import { useAppSelector } from "../../hooks"; +import OutlookTable from "./OutlookTable"; + +export default function Outlook() { + const weather = useAppSelector((state) => state.weather.weather); + + if (weather === "failed") return; + if (!weather || weather === "pending") return; + + return ; +} diff --git a/src/features/outlook/OutlookRow.tsx b/src/features/outlook/OutlookRow.tsx new file mode 100644 index 0000000..4a6633c --- /dev/null +++ b/src/features/outlook/OutlookRow.tsx @@ -0,0 +1,158 @@ +import { formatInTimeZone } from "date-fns-tz"; +import { timeZoneSelector } from "../weather/weatherSlice"; +import { useAppSelector } from "../../hooks"; +import styled from "@emotion/styled"; +import WindIndicator from "../rap/WindIndicator"; +import WindSpeed from "./WindSpeed"; +import { + faCloudMoon, + faClouds, + faCloudsMoon, + faCloudsSun, + faCloudSun, + faMoon, + faSun, +} from "@fortawesome/pro-duotone-svg-icons"; +import SunCalc from "suncalc"; +import { TemperatureText } from "../rap/cells/Temperature"; +import { Aside } from "../rap/cells/Altitude"; +import { + TemperatureUnit, + TimeFormat, +} from "../rap/extra/settings/settingEnums"; +import { cToF } from "../weather/aviation/DetailedAviationReport"; +import { Observations } from "../weather/header/Weather"; +import { NWSWeatherObservation } from "../../services/nwsWeather"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; + +const Row = styled.tr<{ day: boolean }>` + display: flex; + + > * { + flex: 1; + } + + border-bottom: 1px solid #77777715; + + background: ${({ day }) => (day ? "#ffffff07" : "transparent")}; +`; + +const TimeCell = styled.td` + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; +`; + +const StyledObservations = styled(Observations)` + font-size: 1em; + margin-right: 0; +`; + +interface OutlookRowProps { + hour: Date; + windDirection: number; + windSpeed: number; + windGust: number; + temperature: number; + observations: NWSWeatherObservation[] | number; + skyCover: number; +} + +export default function OutlookRow({ + hour, + windDirection, + windSpeed, + windGust, + temperature: inCelsius, + observations, + skyCover, +}: OutlookRowProps) { + const timeFormat = useAppSelector((state) => state.user.timeFormat); + + const timeZone = useAppSelector(timeZoneSelector); + if (!timeZone) throw new Error("timeZone needed"); + + const coordinates = useAppSelector((state) => state.weather.coordinates); + if (!coordinates) throw new Error("coordinates not found"); + + const temperatureUnit = useAppSelector((state) => state.user.temperatureUnit); + + const time = formatInTimeZone(hour, timeZone, timeFormatString(timeFormat)); + + const isDay = + SunCalc.getPosition(hour, coordinates.lat, coordinates.lon).altitude > 0; + + const temperatureUnitLabel = (() => { + switch (temperatureUnit) { + case TemperatureUnit.Celsius: + return "C"; + case TemperatureUnit.Fahrenheit: + return "F"; + } + })(); + + const temperature = (() => { + switch (temperatureUnit) { + case TemperatureUnit.Celsius: + return inCelsius; + case TemperatureUnit.Fahrenheit: + return cToF(inCelsius); + } + })(); + + console.log(observations); + + return ( + + {time} + + + + + + {Math.round(temperature)} {" "} + + + + + + + + + + + ); +} + +function timeFormatString(timeFormat: TimeFormat): string { + switch (timeFormat) { + case TimeFormat.Twelve: + return "hha"; + case TimeFormat.TwentyFour: + return "HHmm"; + } +} + +function getDefaultIcon(skyCover: number, isDay: boolean): IconProp { + switch (true) { + case skyCover < 20: + return isDay ? faSun : faMoon; + case skyCover < 60: + return isDay ? faCloudSun : faCloudMoon; + case skyCover < 80: + return isDay ? faCloudsSun : faCloudsMoon; + default: + return faClouds; + } +} diff --git a/src/features/outlook/OutlookTable.tsx b/src/features/outlook/OutlookTable.tsx new file mode 100644 index 0000000..6d65bad --- /dev/null +++ b/src/features/outlook/OutlookTable.tsx @@ -0,0 +1,117 @@ +import { addDays, eachHourOfInterval, startOfDay } from "date-fns"; +import { findValue, NWSWeather } from "../../services/nwsWeather"; +import { Weather } from "../weather/weatherSlice"; +import { OpenMeteoWeather } from "../../services/openMeteo"; +import { useMemo } from "react"; +import OutlookRow from "./OutlookRow"; +import compact from "lodash/fp/compact"; +import styled from "@emotion/styled"; +import Day from "./Day"; + +const Rows = styled.div``; + +interface OutlookTableProps { + weather: Weather; +} + +function getOutlook( + mapFn: (hour: Date, index: number) => React.ReactNode | undefined, +) { + const hours = eachHourOfInterval({ + start: new Date(), + end: addDays(new Date(), 7), + }); + + const data = compact( + hours.map((hour, index) => ({ node: mapFn(hour, index), hour })), + ); + + return Object.entries( + Object.groupBy(data, ({ hour }) => startOfDay(hour).getTime()), + ).map(([timeStr, hours]) => ({ + date: new Date(+timeStr), + hours: hours!.map(({ node }) => node), + })); +} + +export default function OutlookTable({ weather }: OutlookTableProps) { + const rows = (() => { + if ("properties" in weather) return ; + return ; + })(); + + return {rows}; +} + +function NWSOutlookRows({ weather }: { weather: NWSWeather }) { + const days = useMemo( + () => + getOutlook((hour, index) => { + const windDirection = findValue( + hour, + weather.properties.windDirection, + )?.value; + const windSpeed = findValue(hour, weather.properties.windSpeed)?.value; + const windGust = findValue(hour, weather.properties.windGust)?.value; + const temperature = findValue( + hour, + weather.properties.temperature, + )?.value; + const observations = findValue(hour, weather.properties.weather)?.value; + const skyCover = findValue(hour, weather.properties.skyCover)?.value; + + if (windDirection == null) return; + if (windSpeed == null) return; + if (windGust == null) return; + if (temperature == null) return; + if (observations == null) return; + if (skyCover == null) return; + + return ( + + ); + }), + [weather], + ); + + return days.map(({ date, hours }, index) => ( + + )); +} +function OpenMeteoOutlookRows({ weather }: { weather: OpenMeteoWeather }) { + const days = useMemo( + () => + getOutlook((hour, index) => { + const data = weather.byUnixTimestamp[hour.getTime() / 1_000]; + + if (!data) return; + + return ( + + ); + }), + [weather], + ); + + return days.map(({ date, hours }, index) => ( + + )); +} diff --git a/src/features/outlook/WindSpeed.tsx b/src/features/outlook/WindSpeed.tsx new file mode 100644 index 0000000..ca9522b --- /dev/null +++ b/src/features/outlook/WindSpeed.tsx @@ -0,0 +1,49 @@ +import styled from "@emotion/styled"; +import { formatWind } from "../../helpers/taf"; +import { useAppSelector } from "../../hooks"; +import { SpeedUnit } from "metar-taf-parser"; +import { toMph, WindIcon } from "../weather/header/Wind"; +import { HeaderType } from "../weather/WeatherHeader"; +import { faWindsock } from "@fortawesome/pro-duotone-svg-icons"; + +const Speed = styled.div` + word-spacing: -2px; +`; + +const StyledWindIcon = styled(WindIcon)` + margin-right: 12px; +`; +interface WindSpeedProps { + speed: number; + gust: number; +} + +export default function WindSpeed({ speed, gust }: WindSpeedProps) { + const speedUnit = useAppSelector((state) => state.user.speedUnit); + const speedFormatted = formatWind( + speed, + SpeedUnit.KilometersPerHour, + speedUnit, + false, + ); + const gustFormatted = formatWind( + gust, + SpeedUnit.KilometersPerHour, + speedUnit, + false, + ); + + return ( + <> + + + {speedFormatted}G{gustFormatted} + + + ); +} diff --git a/src/features/rap/extra/Extra.tsx b/src/features/rap/extra/Extra.tsx index 2c3c37f..41368cd 100644 --- a/src/features/rap/extra/Extra.tsx +++ b/src/features/rap/extra/Extra.tsx @@ -2,6 +2,7 @@ import React, { lazy, Suspense } from "react"; import styled from "@emotion/styled"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { + faCalendarAlt, faCog, faFileAlt, faLongArrowRight, @@ -19,6 +20,7 @@ import InstallPrompt from "../../install/InstallPrompt"; import { isAfter } from "date-fns"; import Spinner from "../../../shared/Spinner"; import { useTranslation } from "react-i18next"; +import Outlook from "../../outlook/Outlook"; const ReportMetadata = lazy(() => import("./reportMetadata/ReportMetadata")); @@ -37,6 +39,16 @@ const Container = styled.div` margin: 0 auto; `; +const Line = styled.div` + display: flex; + gap: 1rem; + + > * { + flex: 1; + min-width: 0; + } +`; + export default function Extra() { const { t } = useTranslation(); const weather = useAppSelector((state) => state.weather.weather); @@ -63,6 +75,17 @@ export default function Extra() { + + {t("Extended Forecast")} + + } + title={t("Extended Forecast")} + > + + + {discussion !== "not-available" ? ( ) : undefined} - - {t("Report Metadata")} - - } - title={t("Report Metadata")} - > - }> - - - + + + {t("Metadata")} + + } + title={t("Report Metadata")} + > + }> + + + - - {t("Settings")} - - } - title={t("Settings")} - > - - + + {t("Settings")} + + } + title={t("Settings")} + > + + + ); } @@ -125,6 +150,8 @@ const ItemContainer = styled("div", { transition: 100ms linear; transition-property: opacity, filter; + container-type: inline-size; + ${({ loading }) => loading && css` @@ -147,6 +174,7 @@ const IconContainer = styled.div<{ height: 1.85rem; position: relative; margin-right: 1rem; + flex-shrink: 0; &::before { content: ""; @@ -193,6 +221,15 @@ const IconContainer = styled.div<{ const RightArrow = styled(FontAwesomeIcon)` margin-left: auto; opacity: 0.5; + + @container (max-width: 140px) { + display: none; + } +`; + +const Text = styled.span` + text-overflow: ellipsis; + overflow: hidden; `; interface IconProps { @@ -221,7 +258,7 @@ function Item({ {!loading ? : } - {children} + {children} ); diff --git a/src/features/weather/header/NWSWeather.tsx b/src/features/weather/header/NWSWeather.tsx index 37c4e3f..d3afc32 100644 --- a/src/features/weather/header/NWSWeather.tsx +++ b/src/features/weather/header/NWSWeather.tsx @@ -24,16 +24,24 @@ const Flex = styled.div` interface NWSWeatherProps { observations: NWSWeatherObservation[]; + defaultIcon?: IconProp; + className?: string; } -export default function NWSWeather({ observations }: NWSWeatherProps) { +export default function NWSWeather({ + observations, + defaultIcon, + ...rest +}: NWSWeatherProps) { const observation: NWSWeatherObservation | undefined = observations.find(({ weather }) => weather === "thunderstorms") || observations[0]; - if (!observation) return <>; + if (!observation) return renderWithIcon(defaultIcon); function renderTooltip() { + if (observations.every(({ weather }) => !weather)) return; + return capitalize( observations .map((observation) => @@ -45,21 +53,29 @@ export default function NWSWeather({ observations }: NWSWeatherProps) { const icon = findIconFor(observation); - if (!icon) return <>; + if (!icon) return renderWithIcon(defaultIcon); + + return renderWithIcon(icon); + + function renderWithIcon(icon: IconProp | undefined) { + if (!icon) return <>; - return ( - - - - - - ); + return ( + + + + + + ); + } } function findIconFor(observation: NWSWeatherObservation): IconProp | undefined { diff --git a/src/features/weather/header/WMOWeather.tsx b/src/features/weather/header/WMOWeather.tsx index 1a50241..31447aa 100644 --- a/src/features/weather/header/WMOWeather.tsx +++ b/src/features/weather/header/WMOWeather.tsx @@ -8,6 +8,7 @@ import { import { faSnowflake } from "@fortawesome/pro-light-svg-icons"; import { convertTitleCaseToSpaces } from "../../../helpers/string"; import Tooltip from "../../../shared/Tooltip"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; const Flex = styled.div` display: flex; @@ -47,9 +48,15 @@ enum WMOWeatherCode { interface WMOWeatherCodeProps { wmoCode: WMOWeatherCode; + defaultIcon?: IconProp; + className?: string; } -export default function WMOWeather({ wmoCode }: WMOWeatherCodeProps) { +export default function WMOWeather({ + wmoCode, + defaultIcon, + ...rest +}: WMOWeatherCodeProps) { const icon = (() => { switch (wmoCode) { case WMOWeatherCode.LightRainShowers: @@ -82,13 +89,21 @@ export default function WMOWeather({ wmoCode }: WMOWeatherCodeProps) { } })(); - if (!icon) return <>; + if (!icon) return renderWithIcon(defaultIcon); - return ( - convertTitleCaseToSpaces(WMOWeatherCode[wmoCode])}> - - - - - ); + return renderWithIcon(icon); + + function renderWithIcon(icon: IconProp | undefined) { + if (!icon) return <>; + + return ( + convertTitleCaseToSpaces(WMOWeatherCode[wmoCode])} + > + + + + + ); + } } diff --git a/src/features/weather/header/Weather.tsx b/src/features/weather/header/Weather.tsx index 5f83ad6..a14dd32 100644 --- a/src/features/weather/header/Weather.tsx +++ b/src/features/weather/header/Weather.tsx @@ -4,12 +4,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMemo } from "react"; import { outputP3ColorFromRGB } from "../../../helpers/colors"; -import { findValue } from "../../../services/nwsWeather"; -import { WeatherResult as NWSWeatherResult } from "../weatherSlice"; +import { findValue, NWSWeatherObservation } from "../../../services/nwsWeather"; +import { WeatherResult } from "../weatherSlice"; import { keyframes } from "@emotion/css"; import { css } from "@emotion/react"; import NWSWeather from "./NWSWeather"; import WMOWeather from "./WMOWeather"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; const thunderAnimate = keyframes` 0% { @@ -84,7 +85,7 @@ export const WeatherIcon = styled(FontAwesomeIcon, { interface WeatherProps { date: string; - weather: NWSWeatherResult | undefined; + weather: WeatherResult | undefined; } export default function Weather({ date, weather }: WeatherProps) { @@ -103,8 +104,17 @@ export default function Weather({ date, weather }: WeatherProps) { if (!observations) return <>; - if (Array.isArray(observations)) - return ; + return ; +} + +interface ObservationsProps { + data: number | NWSWeatherObservation[]; + defaultIcon?: IconProp | undefined; + className?: string; +} + +export function Observations({ data, ...rest }: ObservationsProps) { + if (Array.isArray(data)) return ; - return ; + return ; } diff --git a/src/features/weather/header/Wind.tsx b/src/features/weather/header/Wind.tsx index 5f8e077..61d95a2 100644 --- a/src/features/weather/header/Wind.tsx +++ b/src/features/weather/header/Wind.tsx @@ -21,7 +21,7 @@ const colorScale = chroma .scale(["#ffffff66", "#ffffff", "#fffb00", "#ff0000"]) .domain([10, 14, 16, 18]); -const WindIcon = styled(FontAwesomeIcon, { +export const WindIcon = styled(FontAwesomeIcon, { shouldForwardProp: (prop) => prop !== "headerType", })<{ headerType: HeaderType; @@ -130,13 +130,13 @@ export default function Wind({ headerType, date, weather }: WindProps) { ); } -function toMph(speed: number): number { +export function toMph(speed: number): number { return speed * 0.621371; } /** * @returns A "composite" wind value for gusts+sustained, similar to temperature "real feel" */ -function getCompositeWindValue(speed: number, gust: number): number { +export function getCompositeWindValue(speed: number, gust: number): number { return Math.max((gust - speed) * 2.5, speed, gust); } diff --git a/src/features/weather/weatherSlice.ts b/src/features/weather/weatherSlice.ts index aa38e55..4a1f070 100644 --- a/src/features/weather/weatherSlice.ts +++ b/src/features/weather/weatherSlice.ts @@ -18,7 +18,7 @@ import { AxiosError } from "axios"; const UPDATE_INTERVAL_MINUTES = 30; -type Weather = nwsWeather.NWSWeather | openMeteo.OpenMeteoWeather; +export type Weather = nwsWeather.NWSWeather | openMeteo.OpenMeteoWeather; export type WeatherResult = // component has requested a weather, to be batched in next bulk request @@ -585,10 +585,10 @@ export const getWeather = let windsAloft, weather, elevationInM; try { - ({ windsAloft, weather, elevationInM } = await openMeteo.getWindsAloft( - lat, - lon, - )); + [{ windsAloft, elevationInM }, weather] = await Promise.all([ + openMeteo.getWindsAloft(lat, lon), + openMeteo.getWeather(lat, lon), + ]); } catch (error) { if (!isStale()) { dispatch(windsAloftFailed()); @@ -614,14 +614,14 @@ export const getWeather = if (isStale()) return; if (!windsAloft) return; // pending - const { elevation, weather } = windsAloft; + const { elevation } = windsAloft; if (elevation == null) loadElevation(); else dispatch(elevationReceived(elevation)); await Promise.all([ loadNWSAlerts(), - loadWeatherAndDiscussion(weather), + loadWeatherAndDiscussion(), loadAviationWeather(), loadAviationAlerts(), ]); @@ -629,7 +629,6 @@ export const getWeather = async function loadWindsAloft(): Promise< | { elevation?: number; - weather?: Weather; } | undefined > { @@ -661,16 +660,13 @@ export const getWeather = try { // It would be nice in the future to intelligently choose an API // instead of trial and error (and, it would be faster) - const { windsAloft, weather } = await openMeteo.getWindsAloft( - lat, - lon, - ); + const { windsAloft } = await openMeteo.getWindsAloft(lat, lon); if (isStale()) return; dispatch(windsAloftReceived(windsAloft)); - return { elevation: windsAloft.elevationInM, weather }; + return { elevation: windsAloft.elevationInM }; } catch (error) { if (!isStale()) dispatch(windsAloftFailed()); @@ -679,7 +675,7 @@ export const getWeather = } } - async function loadPointData(fallbackWeather?: Weather) { + async function loadPointData() { if (getState().weather.weather === "pending") return; dispatch(weatherLoading()); if (getState().weather.weather !== "pending") return; @@ -731,8 +727,7 @@ export const getWeather = // Likely Mexico or Canada // We still need the timezone, so try to fall back anyways - const weather = - fallbackWeather ?? (await openMeteo.getWeather(lat, lon)); + const weather = await openMeteo.getWeather(lat, lon); if (isStale()) return; @@ -855,8 +850,8 @@ export const getWeather = } } - async function loadWeatherAndDiscussion(fallbackWeather?: Weather) { - const gridId = await loadPointData(fallbackWeather); + async function loadWeatherAndDiscussion() { + const gridId = await loadPointData(); if (isStale()) return; diff --git a/src/locales/de.json b/src/locales/de.json index 093111a..51f5dcf 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -42,5 +42,7 @@ "Lapse rate value information": "Temperaturabnahme <0>{{lapseRate}}", "Spread": "Differenz", "Dewpt": "Taupkt", - "Relative humidity acronym": "LF" + "Relative humidity acronym": "LF", + "Extended Forecast": "Erweiterte Vorhersage", + "Metadata": "Metadaten" } diff --git a/src/locales/en.json b/src/locales/en.json index b9db444..3d1a358 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -42,5 +42,7 @@ "Lapse rate value information": "Lapse rate <0>{{lapseRate}}", "Spread": "Spread", "Dewpt": "Dewpt", - "Relative humidity acronym": "RH" + "Relative humidity acronym": "RH", + "Extended Forecast": "Extended Forecast", + "Metadata": "Metadata" } diff --git a/src/locales/es.json b/src/locales/es.json index 7fde8c3..42b31bb 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -42,5 +42,7 @@ "Lapse rate value information": "Tasa de enfriamiento adiabático <0>{{lapseRate}}", "Spread": "Diferencia", "Dewpt": "Écart", - "Relative humidity acronym": "HR" + "Relative humidity acronym": "HR", + "Extended Forecast": "Pronóstico Extendido", + "Metadata": "Metadatos" } diff --git a/src/locales/fr.json b/src/locales/fr.json index 1e26bc3..2dcc9cc 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -42,5 +42,7 @@ "Lapse rate value information": "Valeur du gradient adiabatique <0>{{lapseRate}}", "Spread": "Écart", "Dewpt": "P. rosée", - "Relative humidity acronym": "HR" + "Relative humidity acronym": "HR", + "Extended Forecast": "Prévisions Étendues", + "Metadata": "Métadonnées" } diff --git a/src/locales/nl.json b/src/locales/nl.json index fe7d2cd..d4ce2fc 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -42,5 +42,7 @@ "Lapse rate value information": "Dalingsnelheid <0>{{lapseRate}}", "Spread": "Verschil", "Dewpt": "Dauwpnt", - "Relative humidity acronym": "RV" + "Relative humidity acronym": "RV", + "Extended Forecast": "Uitgebreide Voorspelling", + "Metadata": "Metadata" } diff --git a/src/services/nwsWeather.ts b/src/services/nwsWeather.ts index 744c28f..053bc17 100644 --- a/src/services/nwsWeather.ts +++ b/src/services/nwsWeather.ts @@ -19,6 +19,7 @@ export interface NWSWeather extends Coordinates { windSpeed: Property; windGust: Property; windDirection: Property; + temperature: Property; /** * The NWS office, like "MKX" diff --git a/src/services/openMeteo.ts b/src/services/openMeteo.ts index e103489..dcef612 100644 --- a/src/services/openMeteo.ts +++ b/src/services/openMeteo.ts @@ -8,7 +8,8 @@ import { notEmpty } from "../helpers/array"; import zipObject from "lodash/zipObject"; import * as velitherm from "velitherm"; -const FORECAST_DAYS = 2; +const FORECAST_DAYS = 7; +const FORECAST_DAYS_WINDS_ALOFT = 2; /** * in hPa @@ -19,10 +20,10 @@ const PRESSURE_ALTITUDES = [ const PRESSURE_ALTITUDE_METRICS = [ "temperature", - "windspeed", - "winddirection", + "wind_speed", + "wind_direction", "geopotential_height", - "relativehumidity", + "relative_humidity", ] as const; /** @@ -30,31 +31,34 @@ const PRESSURE_ALTITUDE_METRICS = [ */ const AGL_ALTITUDES = [80, 120, 180] as const; -const AGL_METRICS = ["windspeed", "winddirection", "temperature"] as const; +const AGL_METRICS = ["wind_speed", "wind_direction", "temperature"] as const; const SPECIAL_ALOFT_VARIABLES = [ "cape", + "convective_inhibition", "temperature_2m", - "dewpoint_2m", - "relativehumidity_2m", + "dew_point_2m", + "relative_humidity_2m", "surface_pressure", "pressure_msl", // No temperature_10m, so have to break it out from AGL_ALTITUDES // (will fudge it and use temperature_2m) - "winddirection_10m", - "windspeed_10m", - "windgusts_10m", + "wind_direction_10m", + "wind_speed_10m", + "wind_gusts_10m", ] as const; const WEATHER_VARIABLES = [ "precipitation_probability", - "weathercode", - "cloudcover", + "weather_code", + "cloud_cover", + "temperature", - "windspeed_10m", - "windgusts_10m", + "wind_speed_10m", + "wind_gusts_10m", + "wind_direction_10m", ] as const; type HourlyPressureParams = @@ -84,6 +88,8 @@ interface OpenMeteoWeatherHour { /** kph */ windGust: number; cloudCover: number; + windDirection: number; + temperature: number; } export async function getWeather( @@ -110,15 +116,13 @@ export async function getWindsAloft( longitude: number, ): Promise<{ windsAloft: WindsAloftReport; - weather: OpenMeteoWeather; elevationInM: number; }> { - const openMeteoResponse = await getOpenMeteoWindsAloft(latitude, longitude); + const aloft = await getOpenMeteoWindsAloft(latitude, longitude); return { - windsAloft: interpolate(convertOpenMeteoToWindsAloft(openMeteoResponse)), - weather: convertOpenMeteoToWeather(openMeteoResponse), - elevationInM: openMeteoResponse.elevation, + windsAloft: interpolate(convertOpenMeteoToWindsAloft(aloft)), + elevationInM: aloft.elevation, }; } @@ -131,10 +135,12 @@ function convertOpenMeteoToWeather( openMeteoResponse.hourly.time.map((_, index) => ({ precipitationChance: openMeteoResponse.hourly.precipitation_probability[index], - weather: openMeteoResponse.hourly.weathercode[index], - windSpeed: openMeteoResponse.hourly.windspeed_10m[index], - windGust: openMeteoResponse.hourly.windgusts_10m[index], - cloudCover: openMeteoResponse.hourly.cloudcover[index], + weather: openMeteoResponse.hourly.weather_code[index], + windSpeed: openMeteoResponse.hourly.wind_speed_10m[index], + windGust: openMeteoResponse.hourly.wind_gusts_10m[index], + windDirection: openMeteoResponse.hourly.wind_direction_10m[index], + cloudCover: openMeteoResponse.hourly.cloud_cover[index], + temperature: openMeteoResponse.hourly.temperature[index], })), ), }; @@ -205,12 +211,9 @@ async function getOpenMeteoWindsAloft( params: { latitude, longitude, - forecast_days: FORECAST_DAYS, + forecast_days: FORECAST_DAYS_WINDS_ALOFT, timeformat: "unixtime", - hourly: [ - ...generateWindsAloftParams(), - ...generateWeatherParams(), - ].join(","), + hourly: generateWindsAloftParams().join(","), }, }) ).data; @@ -226,14 +229,16 @@ function convertOpenMeteoToWindsAloft( index ], windSpeedInKph: - openMeteoResponse.hourly[`windspeed_${pressureAltitude}hPa`][index], + openMeteoResponse.hourly[`wind_speed_${pressureAltitude}hPa`][index], windDirectionInDeg: - openMeteoResponse.hourly[`winddirection_${pressureAltitude}hPa`][index], + openMeteoResponse.hourly[`wind_direction_${pressureAltitude}hPa`][ + index + ], temperatureInC: openMeteoResponse.hourly[`temperature_${pressureAltitude}hPa`][index], pressure: pressureAltitude, dewpointInC: velitherm.dewPoint( - openMeteoResponse.hourly[`relativehumidity_${pressureAltitude}hPa`][ + openMeteoResponse.hourly[`relative_humidity_${pressureAltitude}hPa`][ index ], openMeteoResponse.hourly[`temperature_${pressureAltitude}hPa`][index], @@ -270,14 +275,15 @@ function convertOpenMeteoToWindsAloft( return { date: new Date(time * 1_000).toISOString(), cape: openMeteoResponse.hourly.cape[index], + cin: openMeteoResponse.hourly.convective_inhibition[index], altitudes: [ { altitudeInM: openMeteoResponse.elevation, - windSpeedInKph: openMeteoResponse.hourly.windspeed_10m[index], + windSpeedInKph: openMeteoResponse.hourly.wind_speed_10m[index], windDirectionInDeg: - openMeteoResponse.hourly.winddirection_10m[index], + openMeteoResponse.hourly.wind_direction_10m[index], temperatureInC: openMeteoResponse.hourly.temperature_2m[index], - dewpointInC: openMeteoResponse.hourly.dewpoint_2m[index], + dewpointInC: openMeteoResponse.hourly.dew_point_2m[index], pressure: Math.round( openMeteoResponse.hourly.surface_pressure[index], ), @@ -287,9 +293,9 @@ function convertOpenMeteoToWindsAloft( AGL_ALTITUDES.map((agl) => ({ altitudeInM: openMeteoResponse.elevation + agl, windSpeedInKph: - openMeteoResponse.hourly[`windspeed_${agl}m`][index], + openMeteoResponse.hourly[`wind_speed_${agl}m`][index], windDirectionInDeg: - openMeteoResponse.hourly[`winddirection_${agl}m`][index], + openMeteoResponse.hourly[`wind_direction_${agl}m`][index], temperatureInC: openMeteoResponse.hourly[`temperature_${agl}m`][index], dewpointInC: velitherm.dewPoint( @@ -297,7 +303,7 @@ function convertOpenMeteoToWindsAloft( agl, openMeteoResponse.hourly[`surface_pressure`][index], openMeteoResponse.hourly[`temperature_${agl}m`][index], - openMeteoResponse.hourly[`relativehumidity_2m`][index], + openMeteoResponse.hourly[`relative_humidity_2m`][index], ), openMeteoResponse.hourly[`temperature_${agl}m`][index], ), diff --git a/src/shared/Tooltip.tsx b/src/shared/Tooltip.tsx index c3dbcfc..297c853 100644 --- a/src/shared/Tooltip.tsx +++ b/src/shared/Tooltip.tsx @@ -52,7 +52,7 @@ export const TooltipContainer = styled.div<{ interactive: boolean }>` interface TooltipProps { children?: React.ReactNode; - contents: () => React.ReactNode; + contents: () => React.ReactNode | undefined; mouseOnly?: boolean; interactive?: boolean; offset?: number; @@ -110,6 +110,10 @@ export default function Tooltip({ }); }, [isMounted]); + const renderedContent = contents(); + + if (!renderedContent) return children; + return ( <> to determine if tooltip is open */ interactive={interactive ?? false} > - {contents()} + {renderedContent}