Skip to content

Commit 0afc695

Browse files
committed
訪問日の注視で絞り込みを外し、天気を実際の予報にする
訪問日で「これだけを表示」にしても、絞り込みを重ねたままだと ピンが1つも出なかった。その日に訪問したスポットは必ず訪問済みで、 訪問状況の既定(未訪問のみ)と必ずぶつかるため。対象は日付で 決まっていて曖昧さが無いので、選んだ日の訪問は絞り込みに関わらず 全部出す。訪問予定リストの注視は未訪問が主で既定とぶつからず、 中を絞って見る使い方があるので従来どおり絞り込みを残す。 天気は常に太陽のアイコンで、その日が晴れると読めてしまっていた。 Open-Meteo から予報を引いてアイコンをその天気にし、予報が無い日 (先 15 日ほどより後・通信の失敗)は「天気」のボタンにする。 リンク先は日付を指定して開ける天気ページが無いままなので AI の まま変えていない。旅程には何十件も並ぶので、予報は地点ぶんを まとめて 1 回で引き、サーバー側で座標と日付をキーに 30 分 キャッシュして上流へは間隔を空けて投げる。CC BY 4.0 なので 出典はリンクの説明と README に書いた。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6877cf6 commit 0afc695

9 files changed

Lines changed: 423 additions & 33 deletions

File tree

CLAUDE.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ Microsoft Copilotは`?q=`が入力欄に入らなくなる回帰が報告され
456456

457457
**線(ルート・経路)は逆に、経由地が絞り込み・非表示で消えてもそのスポットを通る形のまま描く。** 道のりが実際と違う形に歪むのを避けるため。構造上そうなっている —— ルートの線は`route.points`の座標から引き、経路は`spots`全件(+別種別の補完`pathExtraSpots`)から解決していて、どちらもピンを絞った集合(`filteredSpots`)とは無関係。**ピンを絞る処理でこの2つを混ぜないこと。**
458458

459-
例外は**「これだけを表示」だけ**(`filters.isolate`)。その経路のスポットだけを残す表示モードで、**絞り込みより優先する** —— 絞り込みも重ねると、訪問状況の既定(未訪問のみ)では訪問順の経路が1件も残らず、選んでも何も出ないことになるため
459+
例外は**「これだけを表示」だけ**(`filters.isolate`)。その経路のスポットだけを残す表示モードで、**訪問日(`isolate: "visit"`)のときは絞り込みを効かせない**(`skipFiltersInIsolate`。本体・重ね表示の両方)—— その日に訪問したスポットは必ず訪問済みなので、**訪問状況の既定(未訪問のみ)と必ずぶつかり**、絞り込みを重ねると経路の線だけが残ってピンが1つも出ない。対象が日付で決まっていて曖昧さが無いことも根拠で、選んだ日の訪問はシリーズ・カテゴリ・訪問状況に関わらず全部出す。**訪問予定リスト(`isolate: "plan"`)は絞り込みを残す** —— リストのスポットは未訪問が主で既定とぶつからず、こちらは「リストの中を絞って見る」使い方があるため。**非表示(`spot_hides`)はどちらでも効かせる**(絞り込みではなく、そのスポットを見ないというユーザーごとの設定なので)
460460

461461
**クラスタ表示は絞り込みモーダルの「表示」の節(公開スポットのダウンロードの真上)から止められる**(`filters.disableCluster`。既定はまとめる)。止めると全スポットを非クラスタのソースへ回すだけ —— 下記の仕組みをそのまま使う。**経路を表示のトグルも同じ節に置く**(絞り込みではなく地図の見せ方なので、絞り込みの並びから外した。重ね表示の種別ごとの絞り込みモーダルでは`FilterBar`の`showRouteToggle`のまま)。**重ね表示側にも同じ「表示」の節を置き、クラスタ表示を無効化を種別ごとに切り替えられる**(`MapView`の重ね表示絞り込みモーダル。値は同じ`SpotFilters.disableCluster`なので、その種別の地図の設定としてlocalStorageへ保存される=本体とは独立に効く)。重ねた種別のピンが「N件」の丸にまとまったままだと、本体のピンとの位置関係が読めないため。**実装は本体と違ってソースの作り直し**(`setOverlayClusterMode`) —— 重ね表示は種別ごとに1ソースなので、本体のように「非クラスタの別ソースへ回す」形が使えない。GeoJSONソースの`cluster`は作成時にしか決められないので、設定が変わったらsource/layerを消して同じIDで作り直す(`overlayClusteredRef`がいまの状態を覚えている)。**クリックハンドラは付け直さない** —— `map.on(type, layerId, ...)`はレイヤーIDで引き当てるため同じIDで作り直せばそのまま効き、付け直すと二重に発火する。**まとまりの円と件数のレイヤーは止めているときも作る**(クラスタを止めたソースには`point_count`を持つフィーチャが出てこないので何にも一致せず、塗りの上書き`setPaintProperty`やハンドラ登録の分岐を増やさずに済む)。「訪問済みも元のピンで表示」は本体の値を種別をまたいで効かせる設定なのでこの節には出さない。
462462

@@ -688,7 +688,8 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
688688

689689
**リスト詳細から外へ出る導線が2つある**(`VisitPlanListDetailModal`)。
690690

691-
- **各スポットの右の太陽アイコン**は、**そのスポットの「予定の日」の天気をAIに聞く**(`lib/weather.ts`)。
691+
- **各スポットの右の天気**(`WeatherAskLink`)は、**その日の予報のアイコン**を出し、押すと
692+
**そのスポットの「予定の日」の天気をAIに聞く**(`lib/weather.ts`)。
692693
聞く日は**開始日→終了日→今日**の順で決める。**天気サービスのページは日付を指定して開けない**
693694
——Yahoo!天気やtenki.jpのピンポイント予報は地点コード(手元にあるのは緯度経度だけ)が要るうえ
694695
日付も選べず、座標で開ける海外サービス(The Weather Channel の10日間予報など)も「今日から数日」を
@@ -698,6 +699,23 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
698699
スポットの質問と同じ理由)、**予報が出ていない先の日付では平年の傾向を答えるよう頼む**
699700
(10日以上先の予定でも服装・雨具の見当は付くため)。**行のボタンの中には置けない**
700701
(ボタンの入れ子になる)ので、独立したリンクとして訪問済みボタンの隣に並べてある
702+
- **アイコンは実際の予報に合わせる**(`/api/weather` = Open-Meteo。`lib/useSpotsWeather.ts`)。
703+
**予報が無いのに晴れのアイコンを出さない** —— 以前は常に太陽で、「その日は晴れる」と読めた。
704+
予報が引けない日(**先15日ほどより後**・ずっと前・通信の失敗)は**「天気」のボタン**にする
705+
(押せばAIが平年の傾向を答えるので、リンクとしての役目は変わらない)。
706+
絵文字とその日本語は`weatherLook`**WMOの天気コードを範囲で丸めて**決める
707+
(着氷性の霧雨のような細かい区別は旅程の見出しには要らない)
708+
- **予報は地点ぶんまとめて1回で引く。** 旅程には何十件も並ぶので、行ごとに引くと同じ日の
709+
同じ予報を何度も取りに行くことになる。`useSpotsWeather`**依存は座標と日付から作った
710+
文字列だけ**(呼び出し側の配列は描画のたびに作り直されるので、配列を依存に置くと引き続ける)。
711+
**リスト詳細と地図の経路詳細で同じものを出す**(旅程を見る場所が2つあるため)
712+
- **Open-Meteoを選んだのはAPIキーが要らず座標と日付でそのまま引けるため。** データはCC-BY 4.0
713+
なので出典表示が要る —— リンクの説明(`title`/`aria-label`)に「Open-Meteo」を入れてある。
714+
`timezone=auto`は地点ごとに解決されるので、国外のスポットでも「その土地の1日」で返る。
715+
サーバー側で**座標(小数第3位)と日付をキーに30分キャッシュ**し、上流へは**同時に1本・
716+
250ms以上の間隔**で投げる(無料の公開APIなので自分で間隔を空ける)。
717+
予報の範囲外の日は**投げる前に「予報なし」で返す**(範囲外は上流が400を返し、
718+
同じリクエストに乗せた地点すべてが巻き添えになるため)
701719
- **「このリストだけを地図で表示」**`/[type]/map?planList=<id>`へ遷移し、地図側がそのリストを
702720
経路の対象(`filters.planListId`)に選び「これだけを表示」(`isolate: "plan"`)にして、経路全体が
703721
入るよう移動する。**適用は一度だけ**(`appliedPlanListRef`)で、以後は普通の絞り込みと同じ扱い
@@ -727,6 +745,7 @@ tourist spotsのA〜E(かつては`series`、いまは`rank`)はこのリポジ
727745

728746
- 自分でレート制限をかけ、リクエストには識別可能な`User-Agent`(名前+連絡先)を設定すること — Overpass API・Nominatimはこのプロジェクト専有のインフラではなく、無料でコミュニティ運営されているフェアユース前提のサービス
729747
- レンダリング済みHTMLのスクレイピングより、公式API(MediaWiki REST/Action API、Overpass QL)を優先すること
748+
- **実行時に叩く外部API(地名検索・逆ジオのNominatim、天気予報のOpen-Meteo)も同じ扱い。** ブラウザから直接ではなくRoute Handler(`/api/geocode``/api/geocode/reverse``/api/weather`)を通し、識別できる`User-Agent`を付け、**自分でキャッシュと間隔制限をかける**(画面を開くたびに素通しで叩かない)。出典表示の要るデータ(Open-MeteoはCC BY 4.0)は、押す前に読める場所に出典を書く
730749
- 政府や第三者のオープンデータには、このアプリのライセンスと整合しない利用制限(非商用限定など)が付いていることが多い。そうしたデータセットの中身(名称・座標・説明文)をそのまま`db/init/`に転記しないこと。せいぜい「抜けているスポットに気づくためのヒント」として使い、実際のデータ(座標・説明文)はライセンス面で問題のない別ソースから取り直すこと
731750
- 一括でスポットを追加した後は、コミット前に既存行との重複(名前一致・近接座標)がないか確認すること — 既存の`tourist`のシードデータにも、過去のインポートで名前だけの突き合わせをすり抜けた重複に近いものが存在する
732751
- 容量の大きいシードデータ(数千〜数万件規模)は、travel-logリポジトリ本体の`db/init/`に直接コミットせず、外部リポジトリ[travel-log-data](../travel-log-data)側に`<スポットキー>/`フォルダ単位のCSVとして置き、`/[type]/admin`の既存CSVインポート機能で取り込む(詳細はtravel-log-data/README.md参照)。`tourist`(観光地)もこの方式で、`spot_types`の行自体はアプリ初期化時に自動で作られるが、スポットデータは他の種別と同様に手動CSVインポートが必要

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
| フロントエンド | Next.js (App Router) + TypeScript |
5858
| UI | Tailwind CSS |
5959
| 地図 | MapLibre GL JS(OpenStreetMap タイル) |
60+
| 天気予報 | Open-Meteo(CC BY 4.0。旅程の各スポットの予報アイコン) |
6061
| バックエンド | Next.js Route Handlers + PostgreSQL(Docker上でローカル完結) |
6162

6263
## セットアップ

app/api/weather/route.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { NextResponse } from "next/server";
2+
import { getCurrentUserId } from "@/lib/auth/current-user";
3+
import type { DailyWeather } from "@/lib/weather";
4+
5+
/**
6+
* 予定の日・その地点の天気予報(Open-Meteo)。
7+
*
8+
* **Open-Meteoを選んだのは、APIキーが要らず座標と日付でそのまま引けるため。**
9+
* 天気サービスのページは日付を指定して開けない(`lib/weather.ts`)ので、
10+
* 「その日の天気をAIに聞く」リンクは残したまま、アイコンだけを実際の予報に合わせる。
11+
* データはCC-BY 4.0(出典表示が要る。画面のツールチップとREADMEに書いてある)。
12+
*
13+
* **複数の地点を1回のリクエストでまとめて引く**(座標をカンマ区切りで渡せる)。
14+
* 旅程の1画面に何十件も並ぶので、行ごとに引くと同じ日の同じ予報を何度も取りに行く。
15+
*
16+
* `timezone=auto`は地点ごとに解決されるので、国外のスポットでも「その土地の1日」で返る。
17+
*/
18+
19+
/** 予報の対象にできる日の範囲。Open-Meteoの許容(過去92日〜先15日)より内側に取る */
20+
const PAST_DAYS = 85;
21+
const FUTURE_DAYS = 14;
22+
23+
/** 座標を丸める桁。予報の格子は数kmあるので、100m単位まで見れば十分細かい */
24+
const COORD_DIGITS = 3;
25+
26+
/** 1回のリクエストで引ける地点数。これより多い分は予報なしとして返す */
27+
const MAX_POINTS = 60;
28+
29+
const CACHE_TTL_MS = 30 * 60 * 1000;
30+
const CACHE_MAX = 2000;
31+
/** 上流へ連続で投げない間隔。無料の公開APIなので自分で間隔を空ける */
32+
const MIN_UPSTREAM_INTERVAL_MS = 250;
33+
34+
const cache = new Map<string, { at: number; weather: DailyWeather | null }>();
35+
let lastUpstreamAt = 0;
36+
/** 上流への呼び出しを直列につなぐ鎖(同時に何本も投げない) */
37+
let upstreamChain: Promise<unknown> = Promise.resolve();
38+
39+
interface OpenMeteoDaily {
40+
daily?: {
41+
time?: string[];
42+
weather_code?: (number | null)[];
43+
temperature_2m_max?: (number | null)[];
44+
temperature_2m_min?: (number | null)[];
45+
precipitation_probability_max?: (number | null)[];
46+
};
47+
}
48+
49+
/** 今日(JST)。予報の範囲に入っているかの判定に使う */
50+
function todayJst(): Date {
51+
const now = new Date();
52+
return new Date(
53+
Math.floor((now.getTime() + 9 * 3600_000) / 86_400_000) * 86_400_000
54+
);
55+
}
56+
57+
/** その日が予報を引ける範囲にあるか(範囲外は上流が400を返すので、投げる前に落とす) */
58+
function inForecastRange(date: string): boolean {
59+
const target = new Date(date + "T00:00:00Z").getTime();
60+
if (Number.isNaN(target)) return false;
61+
const today = todayJst().getTime();
62+
return (
63+
target >= today - PAST_DAYS * 86_400_000 &&
64+
target <= today + FUTURE_DAYS * 86_400_000
65+
);
66+
}
67+
68+
/** "35.658,139.701;34.702,135.495" → 座標の配列。壊れた要素はnullにして位置を保つ */
69+
function parsePoints(raw: string): ({ lat: number; lng: number } | null)[] {
70+
return raw.split(";").map((part) => {
71+
const [lat, lng] = part.split(",").map(Number);
72+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
73+
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
74+
return { lat: Number(lat.toFixed(COORD_DIGITS)), lng: Number(lng.toFixed(COORD_DIGITS)) };
75+
});
76+
}
77+
78+
function cacheKey(point: { lat: number; lng: number }, date: string): string {
79+
return `${point.lat},${point.lng}|${date}`;
80+
}
81+
82+
function readCache(key: string): DailyWeather | null | undefined {
83+
const hit = cache.get(key);
84+
if (!hit) return undefined;
85+
if (Date.now() - hit.at > CACHE_TTL_MS) {
86+
cache.delete(key);
87+
return undefined;
88+
}
89+
return hit.weather;
90+
}
91+
92+
function writeCache(key: string, weather: DailyWeather | null): void {
93+
// 取り直せるデータなので、溢れたら丸ごと捨てる(凝った追い出しをする理由がない)
94+
if (cache.size >= CACHE_MAX) cache.clear();
95+
cache.set(key, { at: Date.now(), weather });
96+
}
97+
98+
/** 上流を叩く。前回から間を空け、同時には1本しか投げない */
99+
async function fetchUpstream(
100+
points: { lat: number; lng: number }[],
101+
date: string
102+
): Promise<(DailyWeather | null)[]> {
103+
const run = async () => {
104+
const wait = MIN_UPSTREAM_INTERVAL_MS - (Date.now() - lastUpstreamAt);
105+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
106+
lastUpstreamAt = Date.now();
107+
const params = new URLSearchParams({
108+
latitude: points.map((p) => p.lat).join(","),
109+
longitude: points.map((p) => p.lng).join(","),
110+
daily:
111+
"weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
112+
timezone: "auto",
113+
start_date: date,
114+
end_date: date,
115+
});
116+
const res = await fetch("https://api.open-meteo.com/v1/forecast?" + params, {
117+
headers: {
118+
// 相手のログでどのアプリか分かるようにする(個人の連絡先は載せない)
119+
"User-Agent": "travel-log-personal-app/1.0",
120+
Accept: "application/json",
121+
},
122+
});
123+
if (!res.ok) return points.map(() => null);
124+
const body: unknown = await res.json();
125+
// 地点が1つのときはオブジェクト、複数のときは配列で返る
126+
const list: OpenMeteoDaily[] = Array.isArray(body)
127+
? (body as OpenMeteoDaily[])
128+
: [body as OpenMeteoDaily];
129+
return points.map((_, i) => {
130+
const daily = list[i]?.daily;
131+
const code = daily?.weather_code?.[0];
132+
if (daily?.time?.[0] !== date || code == null) return null;
133+
return {
134+
code,
135+
tmax: daily.temperature_2m_max?.[0] ?? null,
136+
tmin: daily.temperature_2m_min?.[0] ?? null,
137+
pop: daily.precipitation_probability_max?.[0] ?? null,
138+
};
139+
});
140+
};
141+
const queued = upstreamChain.then(run, run);
142+
// 失敗しても鎖を切らない(次のリクエストが投げられなくなるため)
143+
upstreamChain = queued.catch(() => undefined);
144+
return queued;
145+
}
146+
147+
export async function GET(request: Request) {
148+
const userId = await getCurrentUserId();
149+
if (!userId) {
150+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
151+
}
152+
153+
const { searchParams } = new URL(request.url);
154+
const date = searchParams.get("date") ?? "";
155+
const rawPoints = searchParams.get("points") ?? "";
156+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
157+
return NextResponse.json({ error: "date is required" }, { status: 400 });
158+
}
159+
if (!rawPoints) {
160+
return NextResponse.json({ error: "points is required" }, { status: 400 });
161+
}
162+
163+
const points = parsePoints(rawPoints);
164+
// 範囲外の日・多すぎる地点は「予報なし」で返す。呼び出し側は
165+
// 予報が無いときの見せ方(「天気」ボタン)を必ず持っているので、エラーにはしない
166+
if (!inForecastRange(date)) {
167+
return NextResponse.json({ data: points.map(() => null) });
168+
}
169+
170+
const results: (DailyWeather | null)[] = points.map(() => null);
171+
const missing = new Map<string, { lat: number; lng: number }>();
172+
points.forEach((point, i) => {
173+
if (!point || i >= MAX_POINTS) return;
174+
const key = cacheKey(point, date);
175+
const cached = readCache(key);
176+
if (cached !== undefined) results[i] = cached;
177+
else missing.set(key, point);
178+
});
179+
180+
if (missing.size > 0) {
181+
const keys = [...missing.keys()];
182+
let fetched: (DailyWeather | null)[];
183+
try {
184+
fetched = await fetchUpstream([...missing.values()], date);
185+
} catch {
186+
fetched = keys.map(() => null);
187+
}
188+
keys.forEach((key, i) => {
189+
// 取れなかったものは覚えない(通信の失敗を30分引きずらないため)
190+
if (fetched[i]) writeCache(key, fetched[i]);
191+
});
192+
points.forEach((point, i) => {
193+
if (!point || i >= MAX_POINTS) return;
194+
const at = keys.indexOf(cacheKey(point, date));
195+
if (at >= 0) results[i] = fetched[at] ?? null;
196+
});
197+
}
198+
199+
return NextResponse.json({ data: results });
200+
}

0 commit comments

Comments
 (0)