Skip to content

Commit

Permalink
Add extended forecast (#159)
Browse files Browse the repository at this point in the history
  • Loading branch information
aeharding authored Oct 7, 2024
1 parent fb1268a commit f35107f
Show file tree
Hide file tree
Showing 19 changed files with 598 additions and 119 deletions.
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

0 comments on commit f35107f

Please sign in to comment.