Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add extended forecast #159

Merged
merged 8 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/features/outlook/Day.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Table>
<THead>
<tr>
<DayLabelCell>
{formatInTimeZone(date, timeZone, "eeee, LLL d")}
</DayLabelCell>
</tr>
</THead>
<tbody>{hours}</tbody>
</Table>
);
}
11 changes: 11 additions & 0 deletions src/features/outlook/Outlook.tsx
Original file line number Diff line number Diff line change
@@ -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 <OutlookTable weather={weather} />;
}
158 changes: 158 additions & 0 deletions src/features/outlook/OutlookRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Row day={isDay}>
<TimeCell>{time}</TimeCell>
<td
style={{
maxWidth: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<StyledObservations
data={observations}
defaultIcon={getDefaultIcon(skyCover, isDay)}
/>
</td>
<td>
<TemperatureText temperature={inCelsius}>
{Math.round(temperature)} <Aside>°{temperatureUnitLabel}</Aside>{" "}
</TemperatureText>
</td>
<td style={{ maxWidth: "3%" }} />
<td style={{ textAlign: "start" }}>
<WindSpeed speed={windSpeed} gust={windGust} />
</td>
<td style={{ textAlign: "start", maxWidth: "16%" }}>
<WindIndicator direction={windDirection} />
</td>
</Row>
);
}

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;
}
}
117 changes: 117 additions & 0 deletions src/features/outlook/OutlookTable.tsx
Original file line number Diff line number Diff line change
@@ -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 <NWSOutlookRows weather={weather} />;
return <OpenMeteoOutlookRows weather={weather} />;
})();

return <Rows>{rows}</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 (
<OutlookRow
key={index}
hour={hour}
windDirection={windDirection}
windSpeed={windSpeed}
windGust={windGust}
temperature={temperature}
observations={observations}
skyCover={skyCover}
/>
);
}),
[weather],
);

return days.map(({ date, hours }, index) => (
<Day key={index} date={date} hours={hours} />
));
}
function OpenMeteoOutlookRows({ weather }: { weather: OpenMeteoWeather }) {
const days = useMemo(
() =>
getOutlook((hour, index) => {
const data = weather.byUnixTimestamp[hour.getTime() / 1_000];

if (!data) return;

return (
<OutlookRow
key={index}
hour={hour}
windDirection={data.windDirection}
windSpeed={data.windSpeed}
windGust={data.windGust}
temperature={data.temperature}
observations={data.weather}
skyCover={data.cloudCover}
/>
);
}),
[weather],
);

return days.map(({ date, hours }, index) => (
<Day key={index} date={date} hours={hours} />
));
}
49 changes: 49 additions & 0 deletions src/features/outlook/WindSpeed.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Speed>
<StyledWindIcon
headerType={HeaderType.Normal}
speed={Math.round(toMph(speed))}
gust={Math.round(toMph(gust))}
icon={faWindsock}
/>
{speedFormatted}G{gustFormatted}
</Speed>
</>
);
}
Loading
Loading