diff --git a/frontend/src/app/weather/page.tsx b/frontend/src/app/weather/page.tsx index 9d8a6be..6b766fe 100644 --- a/frontend/src/app/weather/page.tsx +++ b/frontend/src/app/weather/page.tsx @@ -1,14 +1,15 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { - LineChart, Line, BarChart, Bar, ComposedChart, + LineChart, Line, ComposedChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; import { api } from '@/lib/api'; import Navbar from '@/components/Navbar'; -import { Cloud, Loader2, Sun, Droplets, Thermometer, Wind } from 'lucide-react'; +import { Cloud, Loader2, Sun, Droplets, Thermometer, Wind, Search } from 'lucide-react'; +// ─── 型定義 ─────────────────────────────────────── interface MonthlyRecord { month: number; temp_mean_avg: number | null; @@ -21,7 +22,6 @@ interface MonthlyRecord { cold_days: number; rainy_days: number; } - interface AnnualSummary { temp_mean_avg: number | null; precip_total: number | null; @@ -29,13 +29,11 @@ interface AnnualSummary { hot_days: number; cold_days: number; } - interface SummaryResponse { year: number; monthly: MonthlyRecord[]; annual: AnnualSummary; } - interface DailyRecord { date: string; temp_mean: number | null; @@ -47,23 +45,33 @@ interface DailyRecord { pressure_min: number | null; } -const MONTH_LABELS = ['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 { 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 toIso(d: Date): string { + return d.toISOString().slice(0, 10); +} +// 2025-03-15 → 3/15 +function fmtAxisDate(dateStr: string): string { + const [, m, d] = dateStr.split('-'); + return `${parseInt(m)}/${parseInt(d)}`; +} +// データ点数に応じたX軸ラベル間隔 +function calcInterval(count: number): number { + if (count <= 30) return 2; // 3日おき + if (count <= 90) return 6; // 週1 + if (count <= 180) return 13; // 2週おき + return 29; // 月1 } -function toChartPrecipSunshine(monthly: MonthlyRecord[]) { +// 月別グラフ用変換 +function toMonthlyTemp(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 toMonthlyPrecip(monthly: MonthlyRecord[]) { return monthly.map((m) => ({ month: MONTH_LABELS[m.month - 1], 降水量: m.precip_total != null ? Math.round(m.precip_total) : null, @@ -71,43 +79,155 @@ function toChartPrecipSunshine(monthly: MonthlyRecord[]) { })); } +// 期間集計 +function calcPeriodStats(records: DailyRecord[]) { + const temps = records.map((r) => r.temp_mean).filter((v): v is number => v != null); + const avgTemp = temps.length ? temps.reduce((a, b) => a + b, 0) / temps.length : null; + const totalPrecip = records.reduce((s, r) => s + (r.precip_mm ?? 0), 0); + const totalSunshine = records.reduce((s, r) => s + (r.sunshine_h ?? 0), 0); + const hotDays = records.filter((r) => r.temp_max != null && r.temp_max >= 35).length; + const coldDays = records.filter((r) => r.temp_min != null && r.temp_min < 0).length; + return { avgTemp, totalPrecip, totalSunshine, hotDays, coldDays }; +} + +// ─── Tooltip フォーマッター ─────────────────────── +const tempFmt = (v: unknown) => { + if (v == null || typeof v !== 'number') return '—'; + return `${v.toFixed(1)}℃`; +}; +const precipFmt = (v: unknown, name: unknown) => { + if (v == null || typeof v !== 'number') return '—'; + return name === '降水量' ? `${v} mm` : `${v} h`; +}; + +// ─── 共通コンポーネント ─────────────────────────── +function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) { + return ( +
+
{icon}

{label}

+

{value}

+
+ ); +} + +function DailyTable({ records }: { records: DailyRecord[] }) { + return ( +
+
+ + + + {['日付','平均気温','最高','最低','日照h','降水mm','最大風速','最低気圧'].map((h) => ( + + ))} + + + + {records.length === 0 ? ( + + ) : records.map((r) => ( + + + + + + + + + + + ))} + +
{h}
データがありません
{r.date}{fmt(r.temp_mean)}℃ + = 35 ? 'text-red-500 font-medium' : 'text-gray-500'}>{fmt(r.temp_max)}℃ + + {fmt(r.temp_min)}℃ + {fmt(r.sunshine_h)} + = 10 ? 'text-blue-600 font-medium' : 'text-gray-700'}>{fmt(r.precip_mm)} + {fmt(r.wind_max)}{fmt(r.pressure_min, 0)}
+
+ {records.length > 0 && ( +
+ {records[0].date} 〜 {records[records.length - 1].date} 全{records.length}件 +
+ )} +
+ ); +} + +// ─── メインコンポーネント ───────────────────────── export default function WeatherPage() { const currentYear = new Date().getFullYear(); - const [year, setYear] = useState(currentYear); - const [tab, setTab] = useState<'chart' | 'summary' | 'recent'>('chart'); + // 表示モード + const [mode, setMode] = useState<'year' | 'period'>('year'); + + // ── 年別モード ── + const [year, setYear] = useState(currentYear); + const [yearTab, setYearTab] = useState<'chart' | 'summary' | 'recent'>('chart'); const [summary, setSummary] = useState(null); const [summaryLoading, setSummaryLoading] = useState(true); - const [recentRecords, setRecentRecords] = useState([]); const [recentLoading, setRecentLoading] = useState(true); + // ── 期間指定モード ── + const defaultEnd = toIso((() => { const d = new Date(); d.setDate(d.getDate() - 1); return d; })()); + const defaultStart = toIso((() => { const d = new Date(); d.setMonth(d.getMonth() - 3); return d; })()); + const [startDate, setStartDate] = useState(defaultStart); + const [endDate, setEndDate] = useState(defaultEnd); + const [periodTab, setPeriodTab] = useState<'chart' | 'list'>('chart'); + const [periodRecords, setPeriodRecords] = useState([]); + const [periodLoading, setPeriodLoading] = useState(false); + const [periodFetched, setPeriodFetched] = useState(false); + + // 年別データ取得 useEffect(() => { setSummaryLoading(true); api.get(`/weather/summary/?year=${year}`) .then((res) => setSummary(res.data)) - .catch((err) => console.error(err)) + .catch(console.error) .finally(() => setSummaryLoading(false)); }, [year]); useEffect(() => { setRecentLoading(true); - const end = new Date(); - end.setDate(end.getDate() - 1); - const start = new Date(end); - start.setDate(start.getDate() - 13); - const fmtDate = (d: Date) => d.toISOString().slice(0, 10); - api.get(`/weather/records/?start=${fmtDate(start)}&end=${fmtDate(end)}`) + const end = new Date(); end.setDate(end.getDate() - 1); + const start = new Date(end); start.setDate(start.getDate() - 13); + api.get(`/weather/records/?start=${toIso(start)}&end=${toIso(end)}`) .then((res) => setRecentRecords([...res.data].reverse())) - .catch((err) => console.error(err)) + .catch(console.error) .finally(() => setRecentLoading(false)); }, []); - const years: number[] = []; - for (let y = currentYear; y >= 2016; y--) years.push(y); + // 期間指定データ取得 + const fetchPeriod = useCallback(() => { + if (!startDate || !endDate) return; + setPeriodLoading(true); + api.get(`/weather/records/?start=${startDate}&end=${endDate}`) + .then((res) => { setPeriodRecords(res.data); setPeriodFetched(true); }) + .catch(console.error) + .finally(() => setPeriodLoading(false)); + }, [startDate, endDate]); - const tempData = summary ? toChartTemp(summary.monthly) : []; - const precipData = summary ? toChartPrecipSunshine(summary.monthly) : []; + const years = Array.from({ length: currentYear - 2015 }, (_, i) => currentYear - i); + + // グラフデータ + const monthlyTemp = summary ? toMonthlyTemp(summary.monthly) : []; + const monthlyPrecip = summary ? toMonthlyPrecip(summary.monthly) : []; + + const periodTempData = periodRecords.map((r) => ({ + date: r.date, label: fmtAxisDate(r.date), + 平均: r.temp_mean, 最高: r.temp_max, 最低: r.temp_min, + })); + const periodPrecipData = periodRecords.map((r) => ({ + date: r.date, label: fmtAxisDate(r.date), + 降水量: r.precip_mm != null ? Math.round(r.precip_mm * 10) / 10 : null, + 日照時間: r.sunshine_h != null ? Math.round(r.sunshine_h * 10) / 10 : null, + })); + + const interval = calcInterval(periodRecords.length); + const showDot = periodRecords.length <= 60; + const periodStats = periodFetched ? calcPeriodStats(periodRecords) : null; return (
@@ -120,309 +240,274 @@ export default function WeatherPage() {

気象データ

- - - - {/* 年間サマリーカード(常に表示) */} - {!summaryLoading && summary && ( -
-
-
- -

年間平均気温

-
-

- {fmt(summary.annual.temp_mean_avg)} -

-
-
-
- -

年間降水量

-
-

- {fmt(summary.annual.precip_total, 0)}mm -

-
-
-
- -

年間日照時間

-
-

- {fmt(summary.annual.sunshine_total, 0)}h -

-
-
-
- -

猛暑日 / 冬日

-
-

- {summary.annual.hot_days}猛暑 - / - {summary.annual.cold_days} -

-
-
- )} - - {/* タブ */} -
- {(['chart', 'summary', 'recent'] as const).map((t) => ( + {/* モード切替 */} +
- ))} + onClick={() => setMode('year')} + className={`px-4 py-1.5 transition-colors ${mode === 'year' ? 'bg-sky-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`} + >年別集計 + +
- {/* ===== グラフタブ ===== */} - {tab === 'chart' && ( - summaryLoading ? ( -
- + {/* ════════════════════════════════════════ + 年別集計モード + ════════════════════════════════════════ */} + {mode === 'year' && ( + <> + {/* 年セレクタ */} +
+
- ) : summary ? ( -
- {/* 気温グラフ */} -
-

月別気温(℃)

- - - - - `${v}℃`} - domain={['auto', 'auto']} - /> - { - if (value == null || typeof value !== 'number') return '—'; - return `${value.toFixed(1)}℃`; - }} - /> - - - - - - -
- - {/* 降水量・日照時間グラフ */} -
-

月別降水量(mm)・日照時間(h)

- - - - - `${v}`} - label={{ value: 'mm', position: 'insideTopLeft', offset: 5, fontSize: 11, fill: '#6b7280' }} - /> - `${v}h`} - /> - { - if (value == null || typeof value !== 'number') return '—'; - return name === '降水量' ? `${value} mm` : `${value} h`; - }} - /> - - - - - + {/* 年間サマリーカード */} + {!summaryLoading && summary && ( +
+ } label="年間平均気温" + value={<>{fmt(summary.annual.temp_mean_avg)}} /> + } label="年間降水量" + value={<>{fmt(summary.annual.precip_total, 0)}mm} /> + } label="年間日照時間" + value={<>{fmt(summary.annual.sunshine_total, 0)}h} /> + } label="猛暑日 / 冬日" + value={<>{summary.annual.hot_days}猛暑/{summary.annual.cold_days}} />
+ )} + {/* タブ */} +
+ {(['chart','summary','recent'] as const).map((t) => ( + + ))}
- ) : ( -
データの取得に失敗しました
- ) - )} - {/* ===== 月別サマリー ===== */} - {tab === 'summary' && ( - summaryLoading ? ( -
- -
- ) : summary ? ( -
-
- - - - - - - - - - - - - - - - {summary.monthly.map((m) => ( - - - - - - - - - - - - ))} - -
平均気温最高(avg)最低(avg)日照h降水mm猛暑日冬日雨天日
{MONTH_LABELS[m.month - 1]} - {m.temp_mean_avg == null ? '—' : ( - = 25 ? 'text-orange-500 font-medium' : m.temp_mean_avg < 5 ? 'text-blue-500 font-medium' : ''}> - {fmt(m.temp_mean_avg)}℃ - - )} - {fmt(m.temp_max_avg)}℃{fmt(m.temp_min_avg)}℃{fmt(m.sunshine_total, 0)}{fmt(m.precip_total, 0)} - {m.hot_days > 0 ? {m.hot_days} : 0} - - {m.cold_days > 0 ? {m.cold_days} : 0} - {m.rainy_days}
-
-
- 猛暑日: 最高気温 ≥ 35℃ 冬日: 最低気温 < 0℃ 雨天日: 降水量 ≥ 1mm -
-
- ) : ( -
データの取得に失敗しました
- ) - )} - - {/* ===== 直近14日 ===== */} - {tab === 'recent' && ( - recentLoading ? ( -
- -
- ) : ( -
-
- - - - - - - - - - - - - - - {recentRecords.length === 0 ? ( - - - - ) : recentRecords.map((r) => ( - - - - - - - - - - - ))} - -
日付平均気温最高最低日照h降水mm最大風速最低気圧
データがありません
{r.date}{fmt(r.temp_mean)}℃ - = 35 ? 'text-red-500 font-medium' : 'text-gray-500'}> - {fmt(r.temp_max)}℃ - - - - {fmt(r.temp_min)}℃ - - {fmt(r.sunshine_h)} - = 10 ? 'text-blue-600 font-medium' : 'text-gray-700'}> - {fmt(r.precip_mm)} - - {fmt(r.wind_max)}{fmt(r.pressure_min, 0)}
-
- {recentRecords.length > 0 && ( -
- 最新: {recentRecords[0].date} 全{recentRecords.length}件表示 + {/* グラフ */} + {yearTab === 'chart' && (summaryLoading + ?
+ : summary ? ( +
+ + + + + + `${v}℃`} domain={['auto','auto']} /> + + + + + + + + + + + + + + `${v}h`} /> + + + + + +
- )} + ) :
データの取得に失敗しました
+ )} + + {/* 月別サマリー */} + {yearTab === 'summary' && (summaryLoading + ?
+ : summary ? ( +
+
+ + + + {['月','平均気温','最高(avg)','最低(avg)','日照h','降水mm','猛暑日','冬日','雨天日'].map((h) => ( + + ))} + + + + {summary.monthly.map((m) => ( + + + + + + + + + + + + ))} + +
{h}
{MONTH_LABELS[m.month - 1]} + {m.temp_mean_avg == null ? '—' : ( + = 25 ? 'text-orange-500 font-medium' : m.temp_mean_avg < 5 ? 'text-blue-500 font-medium' : ''}> + {fmt(m.temp_mean_avg)}℃ + + )} + {fmt(m.temp_max_avg)}℃{fmt(m.temp_min_avg)}℃{fmt(m.sunshine_total, 0)}{fmt(m.precip_total, 0)}{m.hot_days > 0 ? {m.hot_days} : 0}{m.cold_days > 0 ? {m.cold_days} : 0}{m.rainy_days}
+
+
+ 猛暑日: 最高気温 ≥ 35℃ 冬日: 最低気温 < 0℃ 雨天日: 降水量 ≥ 1mm +
+
+ ) :
データの取得に失敗しました
+ )} + + {/* 直近14日 */} + {yearTab === 'recent' && (recentLoading + ?
+ : + )} + + )} + + {/* ════════════════════════════════════════ + 期間指定モード + ════════════════════════════════════════ */} + {mode === 'period' && ( + <> + {/* 期間入力 */} +
+
+ + setStartDate(e.target.value)} + className="px-3 py-1.5 border border-gray-300 rounded-md text-sm" + /> +
+
+ + setEndDate(e.target.value)} + className="px-3 py-1.5 border border-gray-300 rounded-md text-sm" + /> +
+
- ) + + {/* 期間集計カード */} + {periodStats && periodRecords.length > 0 && ( +
+ } label="期間平均気温" + value={<>{fmt(periodStats.avgTemp)}} /> + } label="期間降水量" + value={<>{fmt(periodStats.totalPrecip, 0)}mm} /> + } label="期間日照時間" + value={<>{fmt(periodStats.totalSunshine, 0)}h} /> + } label="猛暑日 / 冬日" + value={<>{periodStats.hotDays}猛暑/{periodStats.coldDays}} /> +
+ )} + + {/* タブ */} + {periodFetched && ( + <> +
+ {(['chart','list'] as const).map((t) => ( + + ))} +
+ + {/* 期間グラフ */} + {periodTab === 'chart' && ( + periodRecords.length === 0 + ?
指定期間にデータがありません
+ : ( +
+ + + + + + `${v}℃`} domain={['auto','auto']} /> + payload?.[0]?.payload?.date ?? ''} formatter={tempFmt} /> + + + + + + + + + + + + + + `${v}h`} /> + payload?.[0]?.payload?.date ?? ''} formatter={precipFmt} /> + + + + + + +
+ ) + )} + + {/* 期間一覧 */} + {periodTab === 'list' && } + + )} + + {/* 初期状態 */} + {!periodFetched && !periodLoading && ( +
+ 開始日・終了日を選択して「表示」を押してください +
+ )} + )}
); } + +// ─── 小コンポーネント ───────────────────────────── +function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +}