Compare commits
2 Commits
bdba3c709c
...
b1cbc771f8
| Author | SHA1 | Date | |
|---|---|---|---|
| b1cbc771f8 | |||
| 169d5e5e59 |
49
components/charts/chart-tooltip.tsx
Normal file
49
components/charts/chart-tooltip.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ChartTooltipPayload {
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string | number;
|
||||||
|
name?: ReactNode;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
label?: ReactNode;
|
||||||
|
labelFormatter?: (label: ReactNode) => ReactNode;
|
||||||
|
payload?: ChartTooltipPayload[];
|
||||||
|
valueFormatter?: (entry: ChartTooltipPayload) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, label, labelFormatter, payload, valueFormatter }: ChartTooltipProps) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-36 rounded-card border border-border/80 bg-surface-raised/95 px-3.5 py-3 text-xs text-foreground shadow-card backdrop-blur-xl">
|
||||||
|
{label !== undefined && (
|
||||||
|
<p className="mb-2 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">
|
||||||
|
{labelFormatter ? labelFormatter(label) : label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"
|
||||||
|
key={`${String(entry.dataKey ?? entry.name ?? "series")}-${index}`}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2 text-muted">
|
||||||
|
{entry.color && (
|
||||||
|
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold tabular-nums text-foreground">
|
||||||
|
{valueFormatter ? valueFormatter(entry) : String(entry.value ?? "—")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { ChartTooltip } from "@/components/charts/chart-tooltip";
|
||||||
import { CHART_COLORS } from "@/lib/chart-theme";
|
import { CHART_COLORS } from "@/lib/chart-theme";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
||||||
@@ -11,10 +12,37 @@ import type { HourlyForecast } from "@/types/forecast";
|
|||||||
|
|
||||||
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
|
|
||||||
|
interface ChartLegendItem {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
marker: "bar" | "line" | "dashed-line";
|
||||||
|
}
|
||||||
|
|
||||||
function formatHour(value: string) {
|
function formatHour(value: string) {
|
||||||
return value.slice(11, 16);
|
return value.slice(11, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChartLegend({ items }: { items: ChartLegendItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-medium text-muted">
|
||||||
|
{items.map((item) => (
|
||||||
|
<span className="inline-flex items-center gap-1.5" key={item.label}>
|
||||||
|
{item.marker === "bar" ? (
|
||||||
|
<span className="h-2.5 w-3 rounded-t-[5px]" style={{ backgroundColor: item.color }} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`h-0 w-5 border-t-2 ${item.marker === "dashed-line" ? "border-dashed" : "border-solid"}`}
|
||||||
|
style={{ borderColor: item.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
const temperatureUnit = useWeatherStore((state) => state.temperatureUnit);
|
||||||
@@ -26,6 +54,14 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
precipitationProbability: hour.precipitationProbability,
|
precipitationProbability: hour.precipitationProbability,
|
||||||
}));
|
}));
|
||||||
const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit);
|
const temperatureUnitLabel = getTemperatureUnitLabel(temperatureUnit);
|
||||||
|
const temperatureLegendItems: ChartLegendItem[] = [
|
||||||
|
{ color: CHART_COLORS.temperature, label: t("forecast.temperature"), marker: "line" },
|
||||||
|
{ color: CHART_COLORS.feelsLike, label: t("forecast.apparentTemperature"), marker: "dashed-line" },
|
||||||
|
];
|
||||||
|
const rainfallLegendItems: ChartLegendItem[] = [
|
||||||
|
{ color: CHART_COLORS.rainfall, label: t("forecast.precipitation"), marker: "bar" },
|
||||||
|
{ color: CHART_COLORS.probability, label: t("forecast.precipitationProbability"), marker: "line" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
@@ -56,19 +92,17 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
unit={temperatureUnitLabel}
|
unit={temperatureUnitLabel}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
cursor={{ stroke: CHART_COLORS.grid, strokeDasharray: "4 4" }}
|
||||||
borderRadius: 14,
|
content={
|
||||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
<ChartTooltip
|
||||||
background: CHART_COLORS.tooltipBackground,
|
valueFormatter={(entry) =>
|
||||||
color: CHART_COLORS.tooltipText,
|
typeof entry.value === "number"
|
||||||
}}
|
? formatTemperatureValue(entry.value, language, temperatureUnit)
|
||||||
formatter={(value) => [
|
: formatForecastTemperature(null, language, temperatureUnit)
|
||||||
typeof value === "number"
|
}
|
||||||
? formatTemperatureValue(value, language, temperatureUnit)
|
/>
|
||||||
: formatForecastTemperature(null, language, temperatureUnit),
|
}
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="temperature"
|
dataKey="temperature"
|
||||||
@@ -91,6 +125,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<ChartLegend items={temperatureLegendItems} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4 sm:p-5">
|
<Card className="p-4 sm:p-5">
|
||||||
@@ -130,20 +165,17 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
unit="%"
|
unit="%"
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
cursor={{ fill: "hsl(var(--border) / 0.18)" }}
|
||||||
borderRadius: 14,
|
content={
|
||||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
<ChartTooltip
|
||||||
background: CHART_COLORS.tooltipBackground,
|
valueFormatter={(entry) =>
|
||||||
color: CHART_COLORS.tooltipText,
|
entry.dataKey === "precipitation"
|
||||||
}}
|
? formatForecastRainfall(typeof entry.value === "number" ? entry.value : null, language)
|
||||||
formatter={(value, name) => [
|
: `${typeof entry.value === "number" ? entry.value : "—"}%`
|
||||||
name === t("forecast.precipitation")
|
}
|
||||||
? formatForecastRainfall(typeof value === "number" ? value : null, language)
|
/>
|
||||||
: `${typeof value === "number" ? value : "—"}%`,
|
}
|
||||||
name,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: "0.72rem" }} />
|
|
||||||
<Bar
|
<Bar
|
||||||
yAxisId="rainfall"
|
yAxisId="rainfall"
|
||||||
dataKey="precipitation"
|
dataKey="precipitation"
|
||||||
@@ -164,6 +196,7 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<ChartLegend items={rainfallLegendItems} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import type { SynopStation } from "@/types/imgw";
|
import type { SynopStation } from "@/types/imgw";
|
||||||
|
import { ChartTooltip } from "@/components/charts/chart-tooltip";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { useWeatherStore } from "@/lib/store";
|
import { useWeatherStore } from "@/lib/store";
|
||||||
@@ -56,7 +57,14 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
|
|||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "hsl(var(--border) / 0.22)" }}
|
cursor={{ fill: "hsl(var(--border) / 0.22)" }}
|
||||||
formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]}
|
content={
|
||||||
|
<ChartTooltip
|
||||||
|
valueFormatter={(entry) => {
|
||||||
|
const row = entry.payload;
|
||||||
|
return `${row?.value ?? "—"} ${row?.unit ?? ""}`.trim();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
|
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user