Weather Station / ECOWITT / DNT
1"use client";
2
3import React, { useEffect, useMemo, useState } from "react";
4import { API_ENDPOINTS } from "@/constants";
5import { useTranslation } from "react-i18next";
6
7interface DayRec {
8 day: string; // YYYY-MM-DD
9 tmax: number | null;
10 tmin: number | null;
11 tavg: number | null;
12 rain_day: number | null;
13}
14
15type HeatmapMetric = "tmax" | "tmin" | "tavg";
16
17function parseISO(d: string): Date {
18 const [y, m, dd] = d.split("-").map(Number);
19 return new Date(y, (m || 1) - 1, dd || 1);
20}
21
22const TEMP_BUCKETS: { max: number; cls: string }[] = [
23 { max: -15, cls: "bg-temp-neg-15" },
24 { max: -10, cls: "bg-temp-neg-10" },
25 { max: -5, cls: "bg-temp-neg-5" },
26 { max: 0, cls: "bg-temp-0" },
27 { max: 5, cls: "bg-temp-5" },
28 { max: 10, cls: "bg-temp-10" },
29 { max: 15, cls: "bg-temp-15" },
30 { max: 20, cls: "bg-temp-20" },
31 { max: 25, cls: "bg-temp-25" },
32 { max: 30, cls: "bg-temp-30" },
33 { max: 35, cls: "bg-temp-35" },
34];
35
36function tempBgClass(v: number | null | undefined): string {
37 if (v === null || v === undefined || !Number.isFinite(v)) return "bg-temp-none";
38 for (const bucket of TEMP_BUCKETS) {
39 if (v <= bucket.max) return bucket.cls;
40 }
41 return "bg-temp-40";
42}
43
44interface CalendarHeatmapProps {
45 year: number;
46 metric?: HeatmapMetric;
47 title?: string;
48 metricLabel?: string;
49}
50
51function formatTemperature(value: number): string {
52 return new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 0 }).format(value);
53}
54
55export default function CalendarHeatmap({ year, metric = "tmax", title, metricLabel }: CalendarHeatmapProps) {
56 const { t } = useTranslation();
57 const [days, setDays] = useState<DayRec[]>([]);
58 const [loading, setLoading] = useState(true);
59
60 const resolvedTitle = useMemo(() => {
61 if (title) return title;
62 if (metric === "tmin") {
63 return t("statistics.heatmapTmin", "Kalender-Heatmap (Tages-Tmin)");
64 }
65 if (metric === "tavg") {
66 return t("statistics.heatmapTavg", "Kalender-Heatmap (Tages-Tavg)");
67 }
68 return t("statistics.heatmapTmax", t("statistics.heatmap", "Kalender-Heatmap (Tages-Tmax)"));
69 }, [metric, t, title]);
70
71 const resolvedMetricLabel = useMemo(() => {
72 if (metricLabel) return metricLabel;
73 if (metric === "tmin") {
74 return t("statistics.dayMinLabel", "Tmin");
75 }
76 if (metric === "tavg") {
77 return t("statistics.dayAvgLabel", "Tavg");
78 }
79 return t("statistics.dayMaxLabel", "Tmax");
80 }, [metric, metricLabel, t]);
81
82 useEffect(() => {
83 let cancelled = false;
84 (async () => {
85 try {
86 setLoading(true);
87 const res = await fetch(`${API_ENDPOINTS.STATISTICS_DAILY}?year=${year}`);
88 const json = await res.json();
89 if (!cancelled && json?.ok && Array.isArray(json.days)) setDays(json.days);
90 } finally {
91 if (!cancelled) setLoading(false);
92 }
93 })();
94 return () => { cancelled = true; };
95 }, [year]);
96
97 const cells = useMemo(() => {
98 // Build a map by date for quick lookup
99 const byDate = new Map<string, DayRec>();
100 for (const r of days) {
101 if (r?.day) byDate.set(r.day, r as any);
102 }
103 const start = new Date(year, 0, 1);
104 const end = new Date(year, 11, 31);
105 const firstWeekdayMon0 = ((start.getDay() + 6) % 7); // 0..6, Monday=0
106 const totalDays = Math.floor((end.getTime() - start.getTime()) / (24*3600*1000)) + 1;
107
108 const list: { key: string; col: number; row: number; cls: string; rain: boolean; title: string }[] = [];
109 for (let i = 0; i < totalDays; i++) {
110 const d = new Date(year, 0, 1 + i);
111 const ymd = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
112 const row = ((d.getDay() + 6) % 7); // Monday=0
113 const col = Math.floor((i + firstWeekdayMon0) / 7);
114 const rec = byDate.get(ymd);
115 const rawValue = rec ? (rec as any)[metric] : null;
116 const value = Number.isFinite(rawValue) ? Number(rawValue) : null;
117 const rain = (rec && Number.isFinite((rec as any).rain_day)) ? Number((rec as any).rain_day) : 0;
118 const cls = `${tempBgClass(value)}${rain > 0 ? " cal-cell--rain" : ""}`;
119 const title = `${ymd}${value !== null ? ` | ${resolvedMetricLabel} ${formatTemperature(value)} °C` : ""}${rain > 0 ? ` | Rain ${rain} mm` : ""}`;
120 list.push({ key: ymd, col, row, cls, rain: rain>0, title });
121 }
122 const cols = list.reduce((m, c) => Math.max(m, c.col), 0) + 1;
123 return { list, cols };
124 }, [days, metric, resolvedMetricLabel, year]);
125
126 if (loading) return (
127 <div className="cal-wrap" aria-hidden>
128 <div className="cal-title">{resolvedTitle}</div>
129 <div className="skeleton skeleton-heatmap" />
130 </div>
131 );
132 return (
133 <div className="cal-wrap" role="region" aria-label={resolvedTitle}>
134 <div className="cal-title">{resolvedTitle}</div>
135 <div className="cal-scroll">
136 <div
137 className="cal-grid"
138 style={{ gridTemplateColumns: `repeat(${cells.cols}, var(--cal-cell, 12px))` } as React.CSSProperties}
139 >
140 {cells.list.map(c => (
141 <div
142 key={c.key}
143 className={`cal-cell ${c.cls}`}
144 style={{ gridColumn: c.col + 1, gridRow: c.row + 1 } as React.CSSProperties}
145 title={c.title}
146 />
147 ))}
148 </div>
149 </div>
150 </div>
151 );
152}