Files
homarr/packages/widgets/src/weather/advanced.tsx
T
2026-08-20 15:16:26 +02:00

440 lines
17 KiB
TypeScript

import type { ReactNode } from "react";
import { useId } from "react";
import { CompositeChart } from "@mantine/charts";
import {
Box,
Group,
Paper,
Popover,
ScrollArea,
SimpleGrid,
Stack,
Text,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
import { IconDroplets, IconMapPin, IconSunHigh, IconSunrise, IconSunset, IconWind } from "@tabler/icons-react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import classes from "./component.module.css";
import { DailyWeatherDetails } from "./details";
import type { HourlyChartRow, WeatherData } from "./format";
import {
getHourlyChartData,
getPreferredDate,
getPreferredTime,
getPreferredUnit,
getPreferredWindSpeed,
} from "./format";
import { getWeatherKind, WeatherDescription } from "./icon";
import { getAdvancedWeatherLayout } from "./layout";
interface AdvancedWeatherProps {
height: number;
options: WidgetProps<"weather">["options"];
weather: WeatherData;
width: number;
}
interface MetricCardProps {
icon: ReactNode;
label: string;
value: ReactNode;
}
interface WeatherChartDatum {
detailLabel: string;
observedAt: string;
precipitationProbability: number;
precipitationSummary: string;
temperature: number;
temperatureSummary: string;
tooltipLabel: string;
}
interface HourlyChartTooltipProps {
active?: boolean;
payload?: Array<{ payload?: WeatherChartDatum }>;
}
const HourlyChartTooltip = ({ active, payload }: HourlyChartTooltipProps) => {
const datum = payload?.[0]?.payload;
if (!active || !datum) return null;
return (
<Paper p="xs" radius="sm" shadow="md" withBorder>
<Text size="sm" fw={600} mb={4}>
{datum.tooltipLabel}
</Text>
<Text size="xs">
{datum.temperatureSummary} · {datum.precipitationSummary}
</Text>
<Text size="xs">{datum.detailLabel}</Text>
</Paper>
);
};
const MetricCard = ({ icon, label, value }: MetricCardProps) => (
<Paper className={classes.metricCard} p="sm" radius="md">
<Group gap="xs" wrap="nowrap" align="flex-start">
{icon}
<Stack gap={0} miw={0}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600} truncate>
{value}
</Text>
</Stack>
</Group>
</Paper>
);
export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWeatherProps) => {
const headingId = useId();
const chartId = useId();
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
const layout = getAdvancedWeatherLayout(width, height);
const today = weather.daily[0];
const nextHour = weather.hourly[0];
const hourly = getHourlyChartData(weather, options.isFormatFahrenheit);
const speedUnit = options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour");
const temperatureUnit = options.isFormatFahrenheit ? "°F" : "°C";
const chartData = hourly.map<WeatherChartDatum>((hour) => {
const condition = hour.weatherCode === null ? t("kind.unknown") : t(`kind.${getWeatherKind(hour.weatherCode)}`);
const temperature = hour.temperature === null ? "?" : hour.temperature.toFixed(1);
const precipitationProbability = hour.precipitationProbability ?? "?";
let detailLabel = t("advanced.hourlyTooltipWithoutHumidity", {
amount: hour.precipitation?.toFixed(1) ?? "?",
feelsLike: hour.apparentTemperature?.toFixed(1) ?? "?",
speed: getPreferredWindSpeed(hour.windSpeed, options.useImperialSpeed),
speedUnit,
temperatureUnit,
});
if (options.showHumidity) {
detailLabel = t("advanced.hourlyTooltip", {
amount: hour.precipitation?.toFixed(1) ?? "?",
feelsLike: hour.apparentTemperature?.toFixed(1) ?? "?",
humidity: hour.relativeHumidity ?? "?",
speed: getPreferredWindSpeed(hour.windSpeed, options.useImperialSpeed),
speedUnit,
temperatureUnit,
});
}
return {
detailLabel,
observedAt: hour.observedAt,
precipitationProbability: hour.precipitationProbability ?? Number.NaN,
precipitationSummary: `${t("advanced.precipitationChance")}: ${precipitationProbability}%`,
temperature: hour.temperature ?? Number.NaN,
temperatureSummary: `${t("advanced.temperature")}: ${temperature}${temperatureUnit}`,
tooltipLabel: `${getPreferredTime(hour.observedAt, locale, weather.timezone)} · ${condition}`,
};
});
const ticks = hourly.filter((_, index) => index % layout.hourlyTickStep === 0).map((hour) => hour.observedAt);
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack className={classes.advancedRoot} gap="md" p="md">
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Group gap="md" wrap="nowrap">
<AnimatedWeatherIcon
animated={options.animateIcons}
code={weather.current.weatherCode}
isDay={weather.current.isDay}
size={56}
/>
<Stack gap={2}>
<Text fz={32} fw={700} lh={1}>
{getPreferredUnit(
weather.current.temperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />
<Text size="sm" c="dimmed">
{t("advanced.feelsLike", {
temperature: getPreferredUnit(
weather.current.apparentTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
),
})}
</Text>
</Stack>
</Group>
<Stack gap={2} align="flex-end" className={classes.locationSummary}>
<Group gap={5} wrap="nowrap" maw="100%">
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm" fw={600} truncate>
{options.location.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("advanced.observedAt", {
time: getPreferredTime(weather.current.observedAt, locale, weather.timezone),
})}
</Text>
{today && (
<Text size="sm">
{t("advanced.highLow", {
maximum: getPreferredUnit(
today.maxTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
),
minimum: getPreferredUnit(
today.minTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
),
})}
</Text>
)}
</Stack>
</Group>
<SimpleGrid cols={layout.metricColumns} spacing="xs">
<MetricCard
icon={<IconDroplets size={18} aria-hidden />}
label={t("advanced.nextHourPrecipitation")}
value={t("advanced.precipitationValue", {
amount: nextHour?.precipitation?.toFixed(1) ?? "?",
probability: nextHour?.precipitationProbability ?? "?",
})}
/>
{options.showHumidity && (
<MetricCard
icon={<IconDroplets size={18} aria-hidden />}
label={t("advanced.currentHumidity")}
value={`${weather.current.relativeHumidity}%`}
/>
)}
<MetricCard
icon={<IconWind size={18} aria-hidden />}
label={t("advanced.currentWind")}
value={t("advanced.windValue", {
gusts: getPreferredWindSpeed(weather.current.windGusts, options.useImperialSpeed),
speed: getPreferredWindSpeed(weather.current.windSpeed, options.useImperialSpeed),
unit: speedUnit,
})}
/>
<MetricCard
icon={<IconSunHigh size={18} aria-hidden />}
label={t("advanced.todayUv")}
value={today?.uvIndex?.toFixed(1) ?? "?"}
/>
<MetricCard
icon={<IconSunrise size={18} aria-hidden />}
label={t("dailyForecast.sunrise")}
value={getPreferredTime(today?.sunriseAt, locale, weather.timezone)}
/>
<MetricCard
icon={<IconSunset size={18} aria-hidden />}
label={t("dailyForecast.sunset")}
value={getPreferredTime(today?.sunsetAt, locale, weather.timezone)}
/>
</SimpleGrid>
{chartData.length > 0 && (
<Box component="figure" m={0} aria-labelledby={chartId}>
<Text id={chartId} component="figcaption" size="sm" fw={600} mb="xs">
{t("advanced.hourlyForecast")}
</Text>
<Box aria-hidden>
<CompositeChart
h={layout.chartHeight}
data={chartData}
dataKey="observedAt"
series={[
{ name: "temperature", label: t("advanced.temperature"), color: "orange.6", type: "area" },
{
name: "precipitationProbability",
label: t("advanced.precipitationChance"),
color: "blue.5",
type: "bar",
yAxisId: "right",
},
]}
curveType="monotone"
gridAxis="y"
tickLine="none"
withDots={false}
withLegend
withRightYAxis={layout.showChartAxes}
withYAxis={layout.showChartAxes}
yAxisProps={{ width: 38, tickFormatter: (value: number) => `${value}°` }}
rightYAxisProps={{ width: 38, domain: [0, 100], tickFormatter: (value: number) => `${value}%` }}
xAxisProps={{
ticks,
interval: 0,
tickFormatter: (value: string) => getPreferredTime(value, locale, weather.timezone),
}}
tooltipAnimationDuration={0}
tooltipProps={{ content: <HourlyChartTooltip /> }}
/>
</Box>
<VisuallyHidden>
<HourlyWeatherTable
hourly={hourly}
locale={locale}
speedUnit={speedUnit}
temperatureUnit={temperatureUnit}
timeZone={weather.timezone}
useImperialSpeed={options.useImperialSpeed}
showHumidity={options.showHumidity}
/>
</VisuallyHidden>
</Box>
)}
<Text id={headingId} component="h3" size="sm" fw={600} m={0}>
{t("advanced.forecast")}
</Text>
<SimpleGrid aria-labelledby={headingId} cols={layout.dailyColumns} spacing="xs">
{weather.daily.map((day) => (
<Popover key={day.date} position="bottom" withArrow shadow="md" offset={10}>
<Popover.Target>
<UnstyledButton
className={classes.dailyCardButton}
aria-label={t("detailsFor", {
date: getPreferredDate(day.date, locale, { weekday: "long", month: "short", day: "numeric" }),
})}
>
<Paper className={classes.dailyCard} p="sm" radius="md">
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={700} tt="capitalize">
{getPreferredDate(day.date, locale, { weekday: "short" })}
</Text>
<Text size="xs" c="dimmed">
{getPreferredDate(day.date, locale, { month: "short", day: "numeric" })}
</Text>
</Stack>
<AnimatedWeatherIcon animated={options.animateIcons} code={day.weatherCode} size={28} />
</Group>
<Text size="sm" fw={500} lineClamp={1}>
{t(`kind.${getWeatherKind(day.weatherCode)}`)}
</Text>
<Group gap="xs" align="baseline" wrap="nowrap">
<Text fz="lg" fw={700}>
{getPreferredUnit(
day.maxTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{getPreferredUnit(
day.minTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
<Group className={classes.dailyCardFacts} justify="space-between" gap={4} wrap="nowrap">
<Group gap={3} wrap="nowrap">
<IconDroplets size={14} aria-hidden />
<Text className={classes.dailyCardFact} fz={11}>
{day.precipitationProbability ?? "?"}%
</Text>
</Group>
<Group gap={3} wrap="nowrap">
<IconWind size={14} aria-hidden />
<Text className={classes.dailyCardFact} fz={11}>
{getPreferredWindSpeed(day.maxWindSpeed, options.useImperialSpeed)} {speedUnit}
</Text>
</Group>
</Group>
</Stack>
</Paper>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.dailyDetailsDropdown}>
<DailyWeatherDetails
animateIcons={options.animateIcons}
day={day}
dateFormat={options.dateFormat}
disableTemperatureDecimals={options.disableTemperatureDecimals}
isFahrenheit={options.isFormatFahrenheit}
showHumidity={options.showHumidity}
timeZone={weather.timezone}
useImperialSpeed={options.useImperialSpeed}
/>
</Popover.Dropdown>
</Popover>
))}
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
interface HourlyDetailProps {
locale: string;
showHumidity: boolean;
speedUnit: string;
temperatureUnit: string;
timeZone: string;
useImperialSpeed: boolean;
}
const HourlyWeatherTable = ({
hourly,
locale,
showHumidity,
speedUnit,
temperatureUnit,
timeZone,
useImperialSpeed,
}: HourlyDetailProps & { hourly: HourlyChartRow[] }) => {
const t = useScopedI18n("widget.weather");
return (
<table>
<caption>{t("advanced.hourlyForecast")}</caption>
<thead>
<tr>
<th>{t("advanced.time")}</th>
<th>{t("advanced.conditions")}</th>
<th>{t("advanced.temperature")}</th>
<th>{t("advanced.feelsLikeLabel")}</th>
<th>{t("advanced.precipitationChance")}</th>
{showHumidity && <th>{t("advanced.humidity")}</th>}
<th>{t("advanced.wind", { unit: speedUnit })}</th>
</tr>
</thead>
<tbody>
{hourly.map((hour) => (
<tr key={hour.observedAt}>
<td>{getPreferredTime(hour.observedAt, locale, timeZone)}</td>
<td>{hour.weatherCode === null ? t("kind.unknown") : t(`kind.${getWeatherKind(hour.weatherCode)}`)}</td>
<td>{hour.temperature === null ? "?" : `${hour.temperature.toFixed(1)}${temperatureUnit}`}</td>
<td>
{hour.apparentTemperature === null ? "?" : `${hour.apparentTemperature.toFixed(1)}${temperatureUnit}`}
</td>
<td>{hour.precipitationProbability === null ? "?" : `${hour.precipitationProbability}%`}</td>
{showHumidity && <td>{hour.relativeHumidity === null ? "?" : `${hour.relativeHumidity}%`}</td>}
<td>
{getPreferredWindSpeed(hour.windSpeed, useImperialSpeed)} /{" "}
{getPreferredWindSpeed(hour.windGusts, useImperialSpeed)} {speedUnit}
</td>
</tr>
))}
</tbody>
</table>
);
};