Skip to content

Commit 42e3afe

Browse files
committed
上流の降水確率を段階に丸め、先の日の予報は薄く出す
Open-Meteoのprecipitation_probability_maxは気象庁が発表する降水確率とは 別物で、「6時間区切りで1mm以上」に対して「時間ごと(閾値0.1mm相当)の日最大」 を返す。定義が違うぶん構造的に高く出るため、数値のまま出すと国内の予報と 突き合わせられて同じものだと誤解される。precipChanceTextで「雨の可能性 低/中/高」に丸め、weatherGradeの判定も同じ境目(POP_HIGH)から取るように して、画面の表記と判定がずれないようにした。 あわせて5日以上先(FAR_FORECAST_DAYS)の予報を薄く出す。その範囲はモデル ごとに解が割れ、気象庁の週間予報でも信頼度の低い日が並ぶ。アイコンを消さ ないのは、気温のように先でも揃う要素が服装の見当を付ける役に立つため。 日付と操作は読めるまま、天気の部分だけ薄くする。 何日先かはJSTの今日から数える(todayKeyJst)。実行環境のTZに依らせると、 サーバーとブラウザで同じ予報が別の「何日先」になる。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3f6579c commit 42e3afe

4 files changed

Lines changed: 122 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1276,7 +1276,19 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
12761276
- **1日ぶんを1行にたたむときは、いちばん悪い地点に合わせる**(`summarizeDay`)。
12771277
旅程は**1か所でも降れば雨具が要る**ので、平均で均すと「全体としては晴れ」に見えてしまう。
12781278
良し悪しの3段階(`weatherGrade`)は**降るかどうか**を境目にし、くもり・霧と
1279-
「晴れているが降水確率50%以上」は言い切らずに間へ置く
1279+
「晴れているが雨の可能性が高い」は言い切らずに間へ置く(境目は下記`POP_HIGH`と共有する)
1280+
- **上流の降水確率は数値で出さない**(`precipChanceText`で「雨の可能性 低/中/高」に丸める)。
1281+
`precipitation_probability_max`**気象庁が発表する降水確率とは別物** ——
1282+
あちらは「6時間区切りで1mm以上」を10%刻みにしたもの、こちらは「時間ごとの降水確率
1283+
(閾値0.1mm相当)の、その日の最大値」で、**定義が違うぶん構造的に高く出る**
1284+
数値のまま並べると国内の予報と突き合わせられ、同じものだと誤解される。
1285+
段階の境目(`POP_MID`=40・`POP_HIGH`=70)が高めなのはこの差を見込んでいるため
1286+
- **`FAR_FORECAST_DAYS`(5日)より先の予報は言い切らない。** そこから先はモデルごとに解が
1287+
割れる(気象庁の週間予報でも信頼度の低い日が並ぶ範囲)ので、`WeatherAskLink`はアイコンを、
1288+
`PlanWeatherFinder`は行の天気部分を薄く出す。**消しはしない** ——
1289+
気温のように先でも揃う要素は服装の見当を付ける役に立つ。
1290+
判定の`isFarForecast`**JSTの今日**から数える(`todayKeyJst`)——
1291+
サーバーとブラウザでTZが違うと、同じ予報が別の「何日先」になる
12801292
- **予報が無い日を空欄にしない。** 予報が出るのは先15日ほどまでなので、遠い予定では
12811293
前後1週間のほとんどが範囲外になる。黙って飛ばすと「候補が無い=悪い日」と読めるため、
12821294
「予報なし」と書いて**おすすめの判定からも外す**(`known === 0`)

components/PlanWeatherFinder.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { useCallback, useEffect, useState } from "react";
44
import { api } from "@/lib/api-client";
55
import {
66
diffDays,
7+
FAR_FORECAST_DAYS,
78
formatWeatherDateWithDay,
9+
isFarForecast,
810
outlookScore,
11+
precipChanceText,
912
shiftDate,
1013
summarizeDay,
1114
weatherLook,
@@ -29,6 +32,10 @@ const WINDOW_DAYS = 7;
2932
* 遠い予定では前後1週間のほとんどが範囲外になる。そこを黙って飛ばすと
3033
* 「候補が無い=悪い日」と読めてしまうため、「予報なし」と書いて数からも外す。
3134
*
35+
* **先の日ほど確度が落ちることを画面に出す。** 5日以上先はモデルごとに解が割れるので
36+
* 薄く表示する(`isFarForecast`)。並びの中で近い日と遠い日が同じ濃さだと、
37+
* 「遠いほうが天気が良い」を根拠にして日をずらしてしまう。
38+
*
3239
* 日付を選ぶと、**旅程の長さを保ったまま**開始日・終了日をずらす
3340
* (3日間の旅程なら3日間のまま動く)。
3441
*/
@@ -130,6 +137,10 @@ export default function PlanWeatherFinder({
130137
<b>いちばん天気の悪いスポットに合わせて</b>表示するので、
131138
どこか1か所でも雨なら雨として出ます。日付を選ぶと、
132139
旅程の長さを保ったまま予定日をずらします。
140+
<b>{FAR_FORECAST_DAYS}日以上先は薄く出します</b>——
141+
そこから先は予報が日替わりで変わるので、日をずらす根拠にはしないでください。
142+
「雨の可能性」は<b>気象庁が発表する降水確率とは別の基準</b>なので、
143+
数値ではなく段階で出しています。
133144
予報の出どころはOpen-Meteo(CC BY 4.0)です。
134145
</HelpTip>
135146
</p>
@@ -151,6 +162,8 @@ export default function PlanWeatherFinder({
151162
{days.map((day) => {
152163
const isPlanned = day.date === date;
153164
const isBest = best != null && day.date === best.date && !isPlanned;
165+
// 確度の落ちる先の日。日付と操作は読めるまま、天気の部分だけ薄くする
166+
const far = isFarForecast(day.date);
154167
return (
155168
<li
156169
key={day.date}
@@ -167,12 +180,21 @@ export default function PlanWeatherFinder({
167180
</span>
168181
) : (
169182
<>
170-
<span aria-hidden="true" className="shrink-0 text-base leading-none">
183+
<span
184+
aria-hidden="true"
185+
className={`shrink-0 text-base leading-none ${
186+
far ? "opacity-50" : ""
187+
}`}
188+
>
171189
{weatherLook((day.worst as DailyWeather).code).icon}
172190
</span>
173-
<span className="min-w-0 flex-1 truncate text-xs text-gray-600">
191+
<span
192+
className={`min-w-0 flex-1 truncate text-xs ${
193+
far ? "text-gray-400" : "text-gray-600"
194+
}`}
195+
>
174196
{weatherLook((day.worst as DailyWeather).code).text}
175-
{day.pop != null && ` 降水${day.pop}%`}
197+
{day.pop != null && ` ${precipChanceText(day.pop)}`}
176198
{day.tmax != null && ` ${Math.round(day.tmax)}℃`}
177199
{day.known > 1 && (
178200
<span className="text-gray-400">
@@ -213,6 +235,8 @@ export default function PlanWeatherFinder({
213235
</p>
214236
)}
215237
<p className="mt-2 text-[11px] text-gray-400">
238+
薄い日は{FAR_FORECAST_DAYS}日以上先で、予報が変わりやすい範囲です。
239+
<br />
216240
予報: Open-Meteo(CC BY 4.0)
217241
</p>
218242
</>

components/WeatherAskLink.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import {
44
buildSpotWeatherAskUrl,
5+
FAR_FORECAST_DAYS,
6+
isFarForecast,
57
weatherLinkLabel,
68
weatherLook,
79
weatherSummary,
@@ -19,6 +21,10 @@ import type { Spot } from "@/lib/types";
1921
* 「その日は晴れる」と読めてしまっていた。予報が出るのは先15日ほどまでなので、
2022
* それより先の予定では「天気」のボタンのまま置く(押せばAIが平年の傾向を答える)。
2123
*
24+
* **先の日の予報は言い切らない。** 5日以上先はモデルごとに解が割れるので、
25+
* アイコンを薄くし、説明にも変わりうることを書く(`isFarForecast`)。
26+
* 消さないのは、気温のように先でも揃う要素は見当を付ける役に立つため。
27+
*
2228
* **リスト詳細と地図の経路詳細で同じものを出す。** 旅程を見る場所は2つあり、
2329
* 片方だけに天気があると「地図から見たときだけ調べ直す」ことになる。
2430
* **行のボタンの中には置けない**(ボタンの入れ子になる)ので、独立したリンクとして隣に並べる。
@@ -36,8 +42,12 @@ export default function WeatherAskLink({
3642
className?: string;
3743
}) {
3844
const ask = weatherLinkLabel(spot.name, date);
45+
const far = isFarForecast(date);
3946
// 予報の出どころ(Open-Meteo)はCC-BYで出典表示が要る。押す前に読める場所に置く
40-
const label = weather ? `${ask}(予報: ${weatherSummary(weather)} / Open-Meteo)` : ask;
47+
const note = far ? `${FAR_FORECAST_DAYS}日以上先なので変わることがあります / ` : "";
48+
const label = weather
49+
? `${ask}(予報: ${weatherSummary(weather)} / ${note}Open-Meteo)`
50+
: ask;
4151
return (
4252
<a
4353
href={buildSpotWeatherAskUrl(spot, date)}
@@ -54,7 +64,9 @@ export default function WeatherAskLink({
5464
}
5565
>
5666
{weather ? (
57-
<span aria-hidden="true">{weatherLook(weather.code).icon}</span>
67+
<span aria-hidden="true" className={far ? "opacity-50" : undefined}>
68+
{weatherLook(weather.code).icon}
69+
</span>
5870
) : (
5971
"天気"
6072
)}

lib/weather.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,17 @@ export function weatherLinkLabel(spotName: string, date: string): string {
7575
export interface DailyWeather {
7676
/** WMOの天気コード(0=快晴 … 95=雷雨) */
7777
code: number;
78-
/** 最高・最低気温(℃)と降水確率(%)。欠けることがあるのでnullを許す */
78+
/** 最高・最低気温(℃)。欠けることがあるのでnullを許す */
7979
tmax: number | null;
8080
tmin: number | null;
81+
/**
82+
* 上流の`precipitation_probability_max`(%)。
83+
* **気象庁の「降水確率」とは別物なので、この数値をそのまま画面に出さない**
84+
* (`precipChanceText`で段階に丸めてから出す)。気象庁が発表するのは
85+
* 「6時間区切りで1mm以上の降水確率」を10%刻みにしたものだが、こちらは
86+
* 「時間ごとの降水確率(閾値0.1mm相当)の、その日の最大値」。
87+
* 定義が違うぶん構造的に高く出るので、並べて比べると食い違って見える。
88+
*/
8189
pop: number | null;
8290
}
8391

@@ -102,6 +110,60 @@ export function weatherLook(code: number): { icon: string; text: string } {
102110
return { icon: "🌡️", text: "天気" };
103111
}
104112

113+
/**
114+
* 降水確率を段階に丸める境目(%)。
115+
*
116+
* **上流の値をそのまま出さないための丸め。** `DailyWeather.pop`は気象庁の降水確率と
117+
* 定義が違い、構造的に高く出る —— 数値で見せると気象庁・民間予報の発表と
118+
* 突き合わせられてしまい、同じものだと誤解される。段階なら、定義の差が
119+
* 吸収できる粒度でしか言わないことになる。
120+
* 境目が40/70と高めなのは、上流の閾値(0.1mm相当)が気象庁(1mm)より低いぶん、
121+
* 同じ空模様でも高い値が返るため。
122+
*/
123+
const POP_MID = 40;
124+
const POP_HIGH = 70;
125+
126+
export type PrecipChance = "low" | "mid" | "high";
127+
128+
export function precipChance(pop: number): PrecipChance {
129+
if (pop >= POP_HIGH) return "high";
130+
if (pop >= POP_MID) return "mid";
131+
return "low";
132+
}
133+
134+
/** 「雨の可能性 中」。`pop`を画面に出すときは必ずこれを通す */
135+
export function precipChanceText(pop: number): string {
136+
const chance = precipChance(pop);
137+
return `雨の可能性 ${chance === "high" ? "高" : chance === "mid" ? "中" : "低"}`;
138+
}
139+
140+
/**
141+
* 予報の確度が落ちる境目(何日先か)。
142+
*
143+
* **これより先はモデルごとに解が割れる。** 気象庁の週間予報も、この辺りから
144+
* 信頼度(A/B/C)の低い日が並ぶようになる。数値を消しはしないが、
145+
* 言い切っているように見せない(画面では薄く出して、変わりうることを添える)。
146+
*/
147+
export const FAR_FORECAST_DAYS = 5;
148+
149+
/**
150+
* 今日(JST)の日付キー。**実行環境のTZに依らせない** ——
151+
* サーバーとブラウザでTZが違うと、同じ予報が別の「何日先」になる。
152+
*/
153+
export function todayKeyJst(): string {
154+
return new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10);
155+
}
156+
157+
/** 今日(JST)から何日先か。過去はマイナス */
158+
export function forecastLeadDays(date: string): number {
159+
return diffDays(todayKeyJst(), date);
160+
}
161+
162+
/** 確度の落ちる先の日かどうか(画面で薄く出す対象) */
163+
export function isFarForecast(date: string): boolean {
164+
return forecastLeadDays(date) >= FAR_FORECAST_DAYS;
165+
}
166+
105167
/**
106168
* 天気の良し悪しを3段階にする。**旅程で知りたいのは「傘が要るか」**なので、
107169
* 降るかどうかを境目に置き、くもりは晴れと雨の間に独立して置く。
@@ -112,8 +174,9 @@ export function weatherGrade(weather: DailyWeather): WeatherGrade {
112174
// 45/48=霧も含めて、降らないが視界・見晴らしが良くないものは fair に寄せる
113175
if (weather.code >= 51) return "bad";
114176
if (weather.code === 3 || weather.code === 45 || weather.code === 48) return "fair";
115-
// 晴れていても降水確率が高い日は言い切らない(予報の幅を潰さないため)
116-
if (weather.pop != null && weather.pop >= 50) return "fair";
177+
// 晴れていても雨の可能性が高い日は言い切らない(予報の幅を潰さないため)。
178+
// 境目はPOP_HIGHと共有する —— 画面の「雨の可能性 高」と判定がずれないようにする
179+
if (weather.pop != null && precipChance(weather.pop) === "high") return "fair";
117180
return "good";
118181
}
119182

@@ -210,14 +273,14 @@ export function diffDays(from: string, to: string): number {
210273
return Math.round((b - a) / 86_400_000);
211274
}
212275

213-
/** 「くもり 26/21℃ 降水20%」。リンクの説明に添える1行 */
276+
/** 「くもり 26/21℃ 雨の可能性 低」。リンクの説明に添える1行 */
214277
export function weatherSummary(weather: DailyWeather): string {
215278
const parts = [weatherLook(weather.code).text];
216279
if (weather.tmax != null || weather.tmin != null) {
217280
const max = weather.tmax != null ? Math.round(weather.tmax) : "-";
218281
const min = weather.tmin != null ? Math.round(weather.tmin) : "-";
219282
parts.push(`${max}/${min}℃`);
220283
}
221-
if (weather.pop != null) parts.push(`降水${weather.pop}%`);
284+
if (weather.pop != null) parts.push(precipChanceText(weather.pop));
222285
return parts.join(" ");
223286
}

0 commit comments

Comments
 (0)