気象データ画面に期間指定モードを追加

- 年別集計 / 期間指定 のモード切替
- 期間指定モード: 開始日・終了日を入力して表示ボタン
- 期間集計カード(平均気温・降水量・日照・猛暑日数)
- 日次グラフ(気温折れ線・降水量棒+日照折れ線)
- データ件数に応じたX軸ラベル間隔の自動調整
- 期間一覧タブ(スクロール対応テーブル)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-02-28 13:53:44 +09:00
parent 3c888f0503
commit adb235250e

View File

@@ -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 (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center gap-1.5 mb-1">{icon}<p className="text-xs text-gray-500">{label}</p></div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
);
}
function DailyTable({ records }: { records: DailyRecord[] }) {
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto max-h-[520px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
<tr>
{['日付','平均気温','最高','最低','日照h','降水mm','最大風速','最低気圧'].map((h) => (
<th key={h} className={`px-3 py-2.5 text-xs font-semibold text-gray-600 whitespace-nowrap ${h === '日付' ? 'text-left' : 'text-right'}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{records.length === 0 ? (
<tr><td colSpan={8} className="px-3 py-8 text-center text-gray-400"></td></tr>
) : records.map((r) => (
<tr key={r.date} className="hover:bg-gray-50">
<td className="px-3 py-1.5 font-medium text-gray-900 whitespace-nowrap">{r.date}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{fmt(r.temp_mean)}</td>
<td className="px-3 py-1.5 text-right">
<span className={r.temp_max != null && r.temp_max >= 35 ? 'text-red-500 font-medium' : 'text-gray-500'}>{fmt(r.temp_max)}</span>
</td>
<td className="px-3 py-1.5 text-right">
<span className={r.temp_min != null && r.temp_min < 0 ? 'text-blue-500 font-medium' : 'text-gray-500'}>{fmt(r.temp_min)}</span>
</td>
<td className="px-3 py-1.5 text-right text-gray-700">{fmt(r.sunshine_h)}</td>
<td className="px-3 py-1.5 text-right">
<span className={r.precip_mm != null && r.precip_mm >= 10 ? 'text-blue-600 font-medium' : 'text-gray-700'}>{fmt(r.precip_mm)}</span>
</td>
<td className="px-3 py-1.5 text-right text-gray-500">{fmt(r.wind_max)}</td>
<td className="px-3 py-1.5 text-right text-gray-500">{fmt(r.pressure_min, 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
{records.length > 0 && (
<div className="px-3 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-400">
{records[0].date} {records[records.length - 1].date} {records.length}
</div>
)}
</div>
);
}
// ─── メインコンポーネント ─────────────────────────
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<SummaryResponse | null>(null);
const [summaryLoading, setSummaryLoading] = useState(true);
const [recentRecords, setRecentRecords] = useState<DailyRecord[]>([]);
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<DailyRecord[]>([]);
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 (
<div className="min-h-screen bg-gray-50">
@@ -120,207 +240,106 @@ export default function WeatherPage() {
<Cloud className="h-6 w-6 text-sky-500" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
{/* モード切替 */}
<div className="flex rounded-lg border border-gray-300 overflow-hidden text-sm">
<button
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'}`}
></button>
<button
onClick={() => setMode('period')}
className={`px-4 py-1.5 transition-colors ${mode === 'period' ? 'bg-sky-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
></button>
</div>
</div>
{/* ════════════════════════════════════════
年別集計モード
════════════════════════════════════════ */}
{mode === 'year' && (
<>
{/* 年セレクタ */}
<div className="flex items-center gap-3 mb-4">
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm"
>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</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>
<StatCard icon={<Thermometer className="h-4 w-4 text-orange-400" />} label="年間平均気温"
value={<>{fmt(summary.annual.temp_mean_avg)}<span className="text-sm font-normal text-gray-500 ml-1"></span></>} />
<StatCard icon={<Droplets className="h-4 w-4 text-blue-400" />} label="年間降水量"
value={<>{fmt(summary.annual.precip_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">mm</span></>} />
<StatCard icon={<Sun className="h-4 w-4 text-yellow-400" />} label="年間日照時間"
value={<>{fmt(summary.annual.sunshine_total, 0)}<span className="text-sm font-normal text-gray-500 ml-1">h</span></>} />
<StatCard icon={<Wind className="h-4 w-4 text-gray-400" />} label="猛暑日 / 冬日"
value={<>{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></>} />
</div>
)}
{/* タブ */}
<div className="flex border-b border-gray-200 mb-6">
{(['chart', 'summary', 'recent'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t
? 'border-sky-500 text-sky-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{(['chart','summary','recent'] as const).map((t) => (
<button key={t} onClick={() => setYearTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${yearTab === t ? 'border-sky-500 text-sky-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t === 'chart' ? 'グラフ' : t === 'summary' ? '月別サマリー' : '直近14日'}
</button>
))}
</div>
{/* ===== グラフタブ ===== */}
{tab === 'chart' && (
summaryLoading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : summary ? (
{/* グラフ */}
{yearTab === '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>
<ChartCard title="月別気温(℃)">
<ResponsiveContainer width="100%" height={280}>
<LineChart data={tempData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<LineChart data={monthlyTemp} 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: unknown) => {
if (value == null || typeof value !== 'number') return '—';
return `${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
/>
<YAxis tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}`} domain={['auto','auto']} />
<Tooltip formatter={tempFmt} /><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>
</ChartCard>
<ChartCard title="月別降水量mm・日照時間h">
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={precipData} margin={{ top: 5, right: 40, left: 0, bottom: 5 }}>
<ComposedChart data={monthlyPrecip} 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: unknown, name: unknown) => {
if (value == null || typeof value !== 'number') 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
/>
<YAxis yAxisId="precip" orientation="left" tick={{ fontSize: 12 }} 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={precipFmt} /><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>
</ChartCard>
</div>
</div>
) : (
<div className="py-20 text-center text-gray-500 text-sm"></div>
)
) : <div className="py-20 text-center text-gray-500 text-sm"></div>
)}
{/* ===== 月別サマリー ===== */}
{tab === 'summary' && (
summaryLoading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : summary ? (
{/* 月別サマリー */}
{yearTab === 'summary' && (summaryLoading
? <div className="flex justify-center py-20"><Loader2 className="h-6 w-6 animate-spin text-gray-400" /></div>
: summary ? (
<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>
{['月','平均気温','最高(avg)','最低(avg)','日照h','降水mm','猛暑日','冬日','雨天日'].map((h) => (
<th key={h} className={`px-3 py-2.5 text-xs font-semibold text-gray-600 whitespace-nowrap ${h === '月' ? 'text-left' : 'text-right'}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
@@ -338,12 +357,8 @@ export default function WeatherPage() {
<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">{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>
))}
@@ -354,75 +369,145 @@ export default function WeatherPage() {
猛暑日: 最高気温 35 冬日: 最低気温 &lt; 0 雨天日: 降水量 1mm
</div>
</div>
) : (
<div className="py-20 text-center text-gray-500 text-sm"></div>
) : <div className="py-20 text-center text-gray-500 text-sm"></div>
)}
{/* 直近14日 */}
{yearTab === 'recent' && (recentLoading
? <div className="flex justify-center py-20"><Loader2 className="h-6 w-6 animate-spin text-gray-400" /></div>
: <DailyTable records={recentRecords} />
)}
</>
)}
{/* ════════════════════════════════════════
期間指定モード
════════════════════════════════════════ */}
{mode === 'period' && (
<>
{/* 期間入力 */}
<div className="bg-white rounded-lg shadow p-4 mb-6 flex flex-wrap items-end gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input
type="date"
value={startDate}
max={endDate}
min="2016-01-01"
onChange={(e) => setStartDate(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input
type="date"
value={endDate}
max={defaultEnd}
min={startDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm"
/>
</div>
<button
onClick={fetchPeriod}
disabled={periodLoading}
className="flex items-center gap-2 px-4 py-1.5 bg-sky-500 text-white rounded-md text-sm hover:bg-sky-600 disabled:opacity-50 transition-colors"
>
{periodLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</button>
</div>
{/* 期間集計カード */}
{periodStats && periodRecords.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<StatCard icon={<Thermometer className="h-4 w-4 text-orange-400" />} label="期間平均気温"
value={<>{fmt(periodStats.avgTemp)}<span className="text-sm font-normal text-gray-500 ml-1"></span></>} />
<StatCard icon={<Droplets className="h-4 w-4 text-blue-400" />} label="期間降水量"
value={<>{fmt(periodStats.totalPrecip, 0)}<span className="text-sm font-normal text-gray-500 ml-1">mm</span></>} />
<StatCard icon={<Sun className="h-4 w-4 text-yellow-400" />} label="期間日照時間"
value={<>{fmt(periodStats.totalSunshine, 0)}<span className="text-sm font-normal text-gray-500 ml-1">h</span></>} />
<StatCard icon={<Wind className="h-4 w-4 text-gray-400" />} label="猛暑日 / 冬日"
value={<>{periodStats.hotDays}<span className="text-sm font-normal text-red-400 ml-1"></span><span className="text-sm font-normal text-gray-400 mx-1">/</span>{periodStats.coldDays}<span className="text-sm font-normal text-blue-400 ml-1"></span></>} />
</div>
)}
{/* タブ */}
{periodFetched && (
<>
<div className="flex border-b border-gray-200 mb-6">
{(['chart','list'] as const).map((t) => (
<button key={t} onClick={() => setPeriodTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${periodTab === t ? 'border-sky-500 text-sky-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t === 'chart' ? 'グラフ' : '一覧'}
</button>
))}
</div>
{/* 期間グラフ */}
{periodTab === 'chart' && (
periodRecords.length === 0
? <div className="py-20 text-center text-gray-500 text-sm"></div>
: (
<div className="space-y-6">
<ChartCard title={`気温(℃) ${startDate}${endDate}`}>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={periodTempData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={interval} />
<YAxis tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}`} domain={['auto','auto']} />
<Tooltip labelFormatter={(_, payload) => payload?.[0]?.payload?.date ?? ''} formatter={tempFmt} />
<Legend />
<Line type="monotone" dataKey="最高" stroke="#f97316" strokeWidth={1.5} dot={showDot ? { r: 2 } : false} connectNulls />
<Line type="monotone" dataKey="平均" stroke="#22c55e" strokeWidth={2} dot={showDot ? { r: 2 } : false} connectNulls />
<Line type="monotone" dataKey="最低" stroke="#3b82f6" strokeWidth={1.5} dot={showDot ? { r: 2 } : false} connectNulls />
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title={`降水量mm・日照時間h ${startDate}${endDate}`}>
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={periodPrecipData} margin={{ top: 5, right: 40, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={interval} />
<YAxis yAxisId="precip" orientation="left" tick={{ fontSize: 12 }} label={{ value: 'mm', position: 'insideTopLeft', offset: 5, fontSize: 11, fill: '#6b7280' }} />
<YAxis yAxisId="sunshine" orientation="right" tick={{ fontSize: 12 }} tickFormatter={(v) => `${v}h`} />
<Tooltip labelFormatter={(_, payload) => payload?.[0]?.payload?.date ?? ''} formatter={precipFmt} />
<Legend />
<Bar yAxisId="precip" dataKey="降水量" fill="#93c5fd" radius={[2,2,0,0]} maxBarSize={12} />
<Line yAxisId="sunshine" type="monotone" dataKey="日照時間" stroke="#fbbf24" strokeWidth={2} dot={showDot ? { r: 2 } : false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
</ChartCard>
</div>
)
)}
{/* ===== 直近14日 ===== */}
{tab === 'recent' && (
recentLoading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</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"></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">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"></th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-gray-600"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{recentRecords.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-gray-400"></td>
</tr>
) : recentRecords.map((r) => (
<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 text-right text-gray-700">{fmt(r.temp_mean)}</td>
<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'}>
{fmt(r.temp_max)}
</span>
</td>
<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'}>
{fmt(r.temp_min)}
</span>
</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">
<span className={r.precip_mm != null && r.precip_mm >= 10 ? 'text-blue-600 font-medium' : 'text-gray-700'}>
{fmt(r.precip_mm)}
</span>
</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>
</tr>
))}
</tbody>
</table>
</div>
{recentRecords.length > 0 && (
<div className="px-3 py-2 bg-gray-50 border-t border-gray-200 text-xs text-gray-400">
: {recentRecords[0].date} {recentRecords.length}
{/* 期間一覧 */}
{periodTab === 'list' && <DailyTable records={[...periodRecords].reverse()} />}
</>
)}
{/* 初期状態 */}
{!periodFetched && !periodLoading && (
<div className="py-20 text-center text-gray-400 text-sm">
</div>
)}
</div>
)
</>
)}
</div>
</div>
);
}
// ─── 小コンポーネント ─────────────────────────────
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white rounded-lg shadow p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-4">{title}</h3>
{children}
</div>
);
}