Weather Station / ECOWITT / DNT
0

Configure Feed

Select the types of activity you want to include in your feed.

at stats2.0 5.4 kB View raw
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}