気象データ画面にグラフ追加(Recharts)

- 月別気温折れ線グラフ(最高・平均・最低)
- 月別降水量棒グラフ + 日照時間折れ線グラフ(右軸)
- recharts ^3.7.0 を依存に追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-02-28 13:46:08 +09:00
parent 8a1887a26d
commit d11e2a708d
2 changed files with 271 additions and 155 deletions

View File

@@ -15,6 +15,7 @@
"next": "14.1.0", "next": "14.1.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,9 +1,13 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import {
LineChart, Line, BarChart, Bar, ComposedChart,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import { Cloud, Loader2, Sun, Droplets, Wind, Thermometer } from 'lucide-react'; import { Cloud, Loader2, Sun, Droplets, Thermometer, Wind } from 'lucide-react';
interface MonthlyRecord { interface MonthlyRecord {
month: number; month: number;
@@ -43,16 +47,34 @@ interface DailyRecord {
pressure_min: number | null; pressure_min: number | null;
} }
const MONTHS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; const MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
function fmt(val: number | null, digits = 1): string { function fmt(val: number | null, digits = 1): string {
return val == null ? '—' : val.toFixed(digits); return val == null ? '—' : val.toFixed(digits);
} }
// null を含むグラフデータを安全に変換
function toChartTemp(monthly: MonthlyRecord[]) {
return monthly.map((m) => ({
month: MONTH_LABELS[m.month - 1],
平均: m.temp_mean_avg,
最高: m.temp_max_avg,
最低: m.temp_min_avg,
}));
}
function toChartPrecipSunshine(monthly: MonthlyRecord[]) {
return monthly.map((m) => ({
month: MONTH_LABELS[m.month - 1],
降水量: m.precip_total != null ? Math.round(m.precip_total) : null,
日照時間: m.sunshine_total != null ? Math.round(m.sunshine_total) : null,
}));
}
export default function WeatherPage() { export default function WeatherPage() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear); const [year, setYear] = useState(currentYear);
const [tab, setTab] = useState<'summary' | 'recent'>('summary'); const [tab, setTab] = useState<'chart' | 'summary' | 'recent'>('chart');
const [summary, setSummary] = useState<SummaryResponse | null>(null); const [summary, setSummary] = useState<SummaryResponse | null>(null);
const [summaryLoading, setSummaryLoading] = useState(true); const [summaryLoading, setSummaryLoading] = useState(true);
@@ -74,12 +96,9 @@ export default function WeatherPage() {
end.setDate(end.getDate() - 1); end.setDate(end.getDate() - 1);
const start = new Date(end); const start = new Date(end);
start.setDate(start.getDate() - 13); start.setDate(start.getDate() - 13);
const fmt2 = (d: Date) => d.toISOString().slice(0, 10); const fmtDate = (d: Date) => d.toISOString().slice(0, 10);
api.get(`/weather/records/?start=${fmt2(start)}&end=${fmt2(end)}`) api.get(`/weather/records/?start=${fmtDate(start)}&end=${fmtDate(end)}`)
.then((res) => { .then((res) => setRecentRecords([...res.data].reverse()))
const data: DailyRecord[] = res.data;
setRecentRecords([...data].reverse());
})
.catch((err) => console.error(err)) .catch((err) => console.error(err))
.finally(() => setRecentLoading(false)); .finally(() => setRecentLoading(false));
}, []); }, []);
@@ -87,6 +106,9 @@ export default function WeatherPage() {
const years: number[] = []; const years: number[] = [];
for (let y = currentYear; y >= 2016; y--) years.push(y); for (let y = currentYear; y >= 2016; y--) years.push(y);
const tempData = summary ? toChartTemp(summary.monthly) : [];
const precipData = summary ? toChartPrecipSunshine(summary.monthly) : [];
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navbar /> <Navbar />
@@ -98,43 +120,183 @@ export default function WeatherPage() {
<Cloud className="h-6 w-6 text-sky-500" /> <Cloud className="h-6 w-6 text-sky-500" />
<h1 className="text-2xl font-bold text-gray-900"></h1> <h1 className="text-2xl font-bold text-gray-900"></h1>
</div> </div>
<div className="flex items-center gap-3"> <select
<select value={year}
value={year} onChange={(e) => setYear(Number(e.target.value))}
onChange={(e) => setYear(Number(e.target.value))} className="px-3 py-1.5 border border-gray-300 rounded-md text-sm"
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm" >
> {years.map((y) => (
{years.map((y) => ( <option key={y} value={y}>{y}</option>
<option key={y} value={y}>{y}</option> ))}
))} </select>
</select>
</div>
</div> </div>
{/* 年間サマリーカード(常に表示) */}
{!summaryLoading && summary && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center gap-1.5 mb-1">
<Thermometer className="h-4 w-4 text-orange-400" />
<p className="text-xs text-gray-500"></p>
</div>
<p className="text-2xl font-bold text-gray-900">
{fmt(summary.annual.temp_mean_avg)}<span className="text-sm font-normal text-gray-500 ml-1"></span>
</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center gap-1.5 mb-1">
<Droplets className="h-4 w-4 text-blue-400" />
<p className="text-xs text-gray-500"></p>
</div>
<p className="text-2xl font-bold text-gray-900">
{fmt(summary.annual.precip_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">mm</span>
</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center gap-1.5 mb-1">
<Sun className="h-4 w-4 text-yellow-400" />
<p className="text-xs text-gray-500"></p>
</div>
<p className="text-2xl font-bold text-gray-900">
{fmt(summary.annual.sunshine_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">h</span>
</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center gap-1.5 mb-1">
<Wind className="h-4 w-4 text-gray-400" />
<p className="text-xs text-gray-500"> / </p>
</div>
<p className="text-2xl font-bold text-gray-900">
{summary.annual.hot_days}<span className="text-sm font-normal text-red-400 ml-1"></span>
<span className="text-sm font-normal text-gray-400 mx-1">/</span>
{summary.annual.cold_days}<span className="text-sm font-normal text-blue-400 ml-1"></span>
</p>
</div>
</div>
)}
{/* タブ */} {/* タブ */}
<div className="flex border-b border-gray-200 mb-6"> <div className="flex border-b border-gray-200 mb-6">
<button {(['chart', 'summary', 'recent'] as const).map((t) => (
onClick={() => setTab('summary')} <button
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ key={t}
tab === 'summary' onClick={() => setTab(t)}
? 'border-sky-500 text-sky-600' className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
: 'border-transparent text-gray-500 hover:text-gray-700' tab === t
}`} ? 'border-sky-500 text-sky-600'
> : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
</button> >
<button {t === 'chart' ? 'グラフ' : t === 'summary' ? '月別サマリー' : '直近14日'}
onClick={() => setTab('recent')} </button>
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ ))}
tab === 'recent'
? 'border-sky-500 text-sky-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
14
</button>
</div> </div>
{/* ===== グラフタブ ===== */}
{tab === 'chart' && (
summaryLoading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : summary ? (
<div className="space-y-6">
{/* 気温グラフ */}
<div className="bg-white rounded-lg shadow p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-4"></h3>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={tempData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(v) => `${v}`}
domain={['auto', 'auto']}
/>
<Tooltip
formatter={(value: number | null) => value == null ? '—' : `${value.toFixed(1)}`}
/>
<Legend />
<Line
type="monotone"
dataKey="最高"
stroke="#f97316"
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
/>
<Line
type="monotone"
dataKey="平均"
stroke="#22c55e"
strokeWidth={2.5}
dot={{ r: 3 }}
connectNulls
/>
<Line
type="monotone"
dataKey="最低"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* 降水量・日照時間グラフ */}
<div className="bg-white rounded-lg shadow p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-4">mmh</h3>
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={precipData} margin={{ top: 5, right: 40, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis
yAxisId="precip"
orientation="left"
tick={{ fontSize: 12 }}
tickFormatter={(v) => `${v}`}
label={{ value: 'mm', position: 'insideTopLeft', offset: 5, fontSize: 11, fill: '#6b7280' }}
/>
<YAxis
yAxisId="sunshine"
orientation="right"
tick={{ fontSize: 12 }}
tickFormatter={(v) => `${v}h`}
/>
<Tooltip
formatter={(value: number | null, name: string) => {
if (value == null) return '—';
return name === '降水量' ? `${value} mm` : `${value} h`;
}}
/>
<Legend />
<Bar
yAxisId="precip"
dataKey="降水量"
fill="#93c5fd"
radius={[3, 3, 0, 0]}
/>
<Line
yAxisId="sunshine"
type="monotone"
dataKey="日照時間"
stroke="#fbbf24"
strokeWidth={2.5}
dot={{ r: 3 }}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
) : (
<div className="py-20 text-center text-gray-500 text-sm"></div>
)
)}
{/* ===== 月別サマリー ===== */} {/* ===== 月別サマリー ===== */}
{tab === 'summary' && ( {tab === 'summary' && (
summaryLoading ? ( summaryLoading ? (
@@ -142,96 +304,51 @@ export default function WeatherPage() {
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div> </div>
) : summary ? ( ) : summary ? (
<div className="space-y-4"> <div className="bg-white rounded-lg shadow overflow-hidden">
{/* 年間サマリーカード */} <div className="overflow-x-auto">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <table className="w-full text-sm">
<div className="bg-white rounded-lg shadow p-4"> <thead className="bg-gray-50 border-b border-gray-200">
<div className="flex items-center gap-1.5 mb-1"> <tr>
<Thermometer className="h-4 w-4 text-orange-400" /> <th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600"></th>
<p className="text-xs text-gray-500"></p> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600"></th>
</div> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">(avg)</th>
<p className="text-2xl font-bold text-gray-900"> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">(avg)</th>
{fmt(summary.annual.temp_mean_avg)}<span className="text-sm font-normal text-gray-500 ml-1"></span> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">h</th>
</p> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">mm</th>
</div> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap"></th>
<div className="bg-white rounded-lg shadow p-4"> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap"></th>
<div className="flex items-center gap-1.5 mb-1"> <th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap"></th>
<Droplets className="h-4 w-4 text-blue-400" /> </tr>
<p className="text-xs text-gray-500"></p> </thead>
</div> <tbody className="divide-y divide-gray-100">
<p className="text-2xl font-bold text-gray-900"> {summary.monthly.map((m) => (
{fmt(summary.annual.precip_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">mm</span> <tr key={m.month} className="hover:bg-gray-50">
</p> <td className="px-3 py-2 font-medium text-gray-900">{MONTH_LABELS[m.month - 1]}</td>
</div> <td className="px-3 py-2 text-right text-gray-700">
<div className="bg-white rounded-lg shadow p-4"> {m.temp_mean_avg == null ? '—' : (
<div className="flex items-center gap-1.5 mb-1"> <span className={m.temp_mean_avg >= 25 ? 'text-orange-500 font-medium' : m.temp_mean_avg < 5 ? 'text-blue-500 font-medium' : ''}>
<Sun className="h-4 w-4 text-yellow-400" /> {fmt(m.temp_mean_avg)}
<p className="text-xs text-gray-500"></p> </span>
</div> )}
<p className="text-2xl font-bold text-gray-900"> </td>
{fmt(summary.annual.sunshine_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">h</span> <td className="px-3 py-2 text-right text-gray-500">{fmt(m.temp_max_avg)}</td>
</p> <td className="px-3 py-2 text-right text-gray-500">{fmt(m.temp_min_avg)}</td>
</div> <td className="px-3 py-2 text-right text-gray-700">{fmt(m.sunshine_total, 0)}</td>
<div className="bg-white rounded-lg shadow p-4"> <td className="px-3 py-2 text-right text-gray-700">{fmt(m.precip_total, 0)}</td>
<div className="flex items-center gap-1.5 mb-1"> <td className="px-3 py-2 text-right">
<Wind className="h-4 w-4 text-gray-400" /> {m.hot_days > 0 ? <span className="text-red-500 font-medium">{m.hot_days}</span> : <span className="text-gray-300">0</span>}
<p className="text-xs text-gray-500"> / </p> </td>
</div> <td className="px-3 py-2 text-right">
<p className="text-2xl font-bold text-gray-900"> {m.cold_days > 0 ? <span className="text-blue-500 font-medium">{m.cold_days}</span> : <span className="text-gray-300">0</span>}
{summary.annual.hot_days}<span className="text-sm font-normal text-red-400 ml-1"></span> </td>
<span className="text-sm font-normal text-gray-400 mx-1">/</span> <td className="px-3 py-2 text-right text-gray-600">{m.rainy_days}</td>
{summary.annual.cold_days}<span className="text-sm font-normal text-blue-400 ml-1"></span>
</p>
</div>
</div>
{/* 月別テーブル */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-gray-600"></th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600"></th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">(avg)</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">(avg)</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">h</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600">mm</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap"></th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap"></th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600 whitespace-nowrap"></th>
</tr> </tr>
</thead> ))}
<tbody className="divide-y divide-gray-100"> </tbody>
{summary.monthly.map((m) => ( </table>
<tr key={m.month} className="hover:bg-gray-50"> </div>
<td className="px-3 py-2 font-medium text-gray-900">{MONTHS[m.month - 1]}</td> <div className="px-3 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-400">
<td className="px-3 py-2 text-right text-gray-700"> 猛暑日: 最高気温 35 冬日: 最低気温 &lt; 0 雨天日: 降水量 1mm
{m.temp_mean_avg == null ? '—' : (
<span className={m.temp_mean_avg >= 25 ? 'text-orange-500 font-medium' : m.temp_mean_avg < 5 ? 'text-blue-500 font-medium' : ''}>
{fmt(m.temp_mean_avg)}
</span>
)}
</td>
<td className="px-3 py-2 text-right text-gray-500">{fmt(m.temp_max_avg)}</td>
<td className="px-3 py-2 text-right text-gray-500">{fmt(m.temp_min_avg)}</td>
<td className="px-3 py-2 text-right text-gray-700">{fmt(m.sunshine_total, 0)}</td>
<td className="px-3 py-2 text-right text-gray-700">{fmt(m.precip_total, 0)}</td>
<td className="px-3 py-2 text-right">
{m.hot_days > 0 ? <span className="text-red-500 font-medium">{m.hot_days}</span> : <span className="text-gray-300">0</span>}
</td>
<td className="px-3 py-2 text-right">
{m.cold_days > 0 ? <span className="text-blue-500 font-medium">{m.cold_days}</span> : <span className="text-gray-300">0</span>}
</td>
<td className="px-3 py-2 text-right text-gray-600">{m.rainy_days}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-3 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-400">
猛暑日: 最高気温 35 冬日: 最低気温 &lt; 0 雨天日: 降水量 1mm
</div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -266,32 +383,30 @@ export default function WeatherPage() {
<tr> <tr>
<td colSpan={8} className="px-3 py-8 text-center text-gray-400"></td> <td colSpan={8} className="px-3 py-8 text-center text-gray-400"></td>
</tr> </tr>
) : ( ) : recentRecords.map((r) => (
recentRecords.map((r) => ( <tr key={r.date} className="hover:bg-gray-50">
<tr key={r.date} className="hover:bg-gray-50"> <td className="px-3 py-2 font-medium text-gray-900 whitespace-nowrap">{r.date}</td>
<td className="px-3 py-2 font-medium text-gray-900 whitespace-nowrap">{r.date}</td> <td className="px-3 py-2 text-right text-gray-700">{fmt(r.temp_mean)}</td>
<td className="px-3 py-2 text-right text-gray-700">{fmt(r.temp_mean)}</td> <td className="px-3 py-2 text-right">
<td className="px-3 py-2 text-right"> <span className={r.temp_max != null && r.temp_max >= 35 ? 'text-red-500 font-medium' : 'text-gray-500'}>
<span className={r.temp_max != null && r.temp_max >= 35 ? 'text-red-500 font-medium' : 'text-gray-500'}> {fmt(r.temp_max)}
{fmt(r.temp_max)} </span>
</span> </td>
</td> <td className="px-3 py-2 text-right">
<td className="px-3 py-2 text-right"> <span className={r.temp_min != null && r.temp_min < 0 ? 'text-blue-500 font-medium' : 'text-gray-500'}>
<span className={r.temp_min != null && r.temp_min < 0 ? 'text-blue-500 font-medium' : 'text-gray-500'}> {fmt(r.temp_min)}
{fmt(r.temp_min)} </span>
</span> </td>
</td> <td className="px-3 py-2 text-right text-gray-700">{fmt(r.sunshine_h)}</td>
<td className="px-3 py-2 text-right text-gray-700">{fmt(r.sunshine_h)}</td> <td className="px-3 py-2 text-right">
<td className="px-3 py-2 text-right"> <span className={r.precip_mm != null && r.precip_mm >= 10 ? 'text-blue-600 font-medium' : 'text-gray-700'}>
<span className={r.precip_mm != null && r.precip_mm >= 10 ? 'text-blue-600 font-medium' : 'text-gray-700'}> {fmt(r.precip_mm)}
{fmt(r.precip_mm)} </span>
</span> </td>
</td> <td className="px-3 py-2 text-right text-gray-500">{fmt(r.wind_max)}</td>
<td className="px-3 py-2 text-right text-gray-500">{fmt(r.wind_max)}</td> <td className="px-3 py-2 text-right text-gray-500">{fmt(r.pressure_min, 0)}</td>
<td className="px-3 py-2 text-right text-gray-500">{fmt(r.pressure_min, 0)}</td> </tr>
</tr> ))}
))
)}
</tbody> </tbody>
</table> </table>
</div> </div>