Skip to content

Commit 6877cf6

Browse files
committed
重なったピンの一覧に戻れるようにし、住所を出す
同じ座標のスポット一覧から詳細へ進むと、閉じたときに一覧へ戻らず ピンを押し直すところからやり直しになっていた。重なっている相方を 続けて見るのが本来の用途なので、開く前の一覧を退避して詳細の onClose で復元する(本体・重ね表示の両方)。一覧自体を閉じたときは 退避も消す。 あわせて見出しの下に簡単な住所を出す。同じ名前のスポットが並ぶ ことがあり、件数だけではどの地点を開いたのか分からないため。 逆ジオの応答に都道府県+市区町村までの address を足し、一覧の 1 件目の座標で 1 回だけ引く。座標をキーに保持して開き直しでは 問い合わせない(1 秒 1 回の利用条件があるため)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 465b727 commit 6877cf6

4 files changed

Lines changed: 121 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ GitHub Actions(`.github/workflows/docker-publish.yml`)がビルド時に`<JST日
157157
1. スポット追加・編集フォームの地域欄(`'jp'`のみ`PREFECTURES`のセレクト、他は自由入力+既存値datalist)
158158
2. `/[type]/spots`の地域タブの名称(`regionFieldLabel`: 都道府県/州・県/国)と並び順(`compareRegions`: `'jp'`はJIS順・リスト外の値も末尾に表示、他は五十音順)
159159
3. 地名検索`/api/geocode``countrycodes`(`scope`クエリパラメータで渡す。`'world'`は絞り込みなし)
160-
4. 逆ジオ`/api/geocode/reverse`の地域解決(`'jp'`=ISO3166-2コード→都道府県、国指定=state/province/county、`'world'`=国名。レスポンスのキーは`region`)
160+
4. 逆ジオ`/api/geocode/reverse`の地域解決(`'jp'`=ISO3166-2コード→都道府県、国指定=state/province/county、`'world'`=国名。レスポンスのキーは`region`)。同じ応答から**表示用の簡単な住所**(`address`)も組む(`resolveShortAddress``'jp'`は都道府県+市区町村+行政区、他は国+州県+市町村。**番地までは出さない** —— `zoom=14`で問い合わせているので建物名や番地は返ってこないうえ、重なったピンの見出しに要るのは「どの辺りか」だけ。前の要素を含む/含まれる名前(「東京都」と「東京」など)は落として重複を避ける)
161161
5. `/[type]/map`初回表示(`'jp'`=従来どおり現在地取得、他=登録スポット全体へfitBounds・スポット0件時は世界全体表示)。地図の表示位置の記憶(`MapView`の`lastViews`。メモリ上のみ。**種別チップから切り替えたときだけは切り替え元の位置を引き継ぐ**。上記「種別の切り替え」)と絞り込み条件の記憶(同`loadSavedFilters`/`saveFilters`。localStorage保存のためアプリを完全に落としても残る。絞り込み中(シリーズ・カテゴリ・訪問状況が既定と違うとき。`hasActiveFilters`。**訪問状況の既定は「未訪問」のみ**で、シリーズ・ランクと同じく「すべて」=空配列 — 全部選んでも「すべて」には切り替わらない。**保存された空配列はそのまま読む**(既定へ倒すと「すべて」を選ぶたびに未訪問へ戻ってしまう)。キー自体が無い旧データだけ既定に倒す)は地図の絞り込みボタンが青くなる。「リセット」ボタンは絞り込みモーダルの見出し行の✕の隣にあり、**地図・スポット一覧とも`FilterBar.tsx`の`FilterResetButton`=絞り込み(シリーズ・カテゴリ・訪問状況)だけを既定に戻す**(`showRoutes`・`disableCluster`・訪問日・訪問予定リスト・「これだけを表示」・重ね表示は対象外で現在値を維持。スポット一覧は`showReset` propで`FilterBar`先頭に内蔵表示)。地図の訪問日・訪問予定リスト・別の種別を重ねて表示の各セクションの見出し行には個別のリセットボタン(`MapView`の`SectionResetButton`)があり、それぞれ今日/表示しない/重ねないへ戻す(訪問日・訪問予定リストのリセットは、そのセクションの「これだけを表示」も解除する))も種別ごとに分けている。現在地追跡モード(GeolocateControlのカメラ追従)中に他画面へ遷移して戻った場合は、位置の復元に加えて追跡モード自体を再開する(`MapView`の`lastTrackingActive`)。なお、PWA(スタンドアロン起動)でアプリを切り替えて戻った場合はページが再読み込みされないため初回表示の現在地取得は走らず、意図的に「開いていた位置をそのまま表示」の挙動にしている(バックグラウンド中に調べ物などで見ていた位置を失わないため)
162162

163163
あわせて`wikipedia_lang`キー(既定`'ja'``resolveWikipediaLang`)でスポット詳細のWikipedia検索(`SpotInfoModal`)の言語版サブドメインを種別ごとに切り替えられる。
@@ -482,6 +482,8 @@ Maps URLsの`waypoints`は9件までのため、それを超える経路は**並
482482

483483
- `buildClusterGeoJSON`が**同じ座標のスポット数を`stack`プロパティ**に持たせ(`countStacks`が`stackKey`=小数第6位=約0.1mまで見て集計)、`STACK_BADGE_LAYER_ID`のsymbolレイヤーがピンの右肩に**重なっている数**(`2`・`3`…)を出す。**隠れている件数(`+N`)ではなく総数**にしてあるのは、ピンが何枚あるのかをそのまま読めるほうが分かりやすいため。**1件のときは出さない**(`stack > 1`のfilter) —— ほとんどのピンに`1`が付くと、重なっている場所を見つけられない。`showClusterLayers`の表示切り替え対象にも含めること。**レイヤーの定義は`addStackBadgeLayer`1か所**で、本体(`addPinLayers`経由)と重ね表示(`addOverlaySpotLayers`経由)が同じものを使う —— 違うのは重ね表示が半透明(`text-opacity`)であることだけ。重ね表示側では`overlayIds`の`stackBadge`・`overlayPinLayerIds`(タップの優先順位)・`moveOverlayLayersToTop`(最上位の維持)の3つにも足すこと(どれか1つでも漏らすと、バッジだけ出ない・本体のピンの下に潜る・押しても本体側にタップを取られる、のいずれかになる)。**件数はソースを分ける前の全スポットで数える**(上記「ピンの表示と線の表示を分ける」)
484484
- ピンと重なり数のバッジのクリックでは、**押されたスポット自身の座標**(`displayedSpotsRef`から`id`で引き当てた元データ)で表示中の全スポットを引き直して同じ地点の分を集め、2件以上なら先頭を開かず**「この地点のスポット」一覧**(`stack` state。`{ ids, overlayTypeKey }`で、`overlayTypeKey`がnullなら本体・そうでなければその重ね表示種別)を出してどれを開くか選ばせる。**描かれているピンから数えてはいけない** —— 拡大率が低いと相方がクラスタに吸われて描かれず、バッジが出ているのに一覧が開かない(実際に踏んだ)。**地図から返るフィーチャの座標も使えない** —— GeoJSONソースは内部でタイルに変換されるので座標が拡大率に応じて丸められ(低い拡大率では数十m単位)、同じ地点の判定に使うと一致しない(クラスタ表示を無効にして低倍率で見ているときに再発した)。バッジにもクリックを付けるのは、数字をピンの右肩にずらして描いていて、そこを押すとピンの当たり判定から外れることがあるため。選ぶと通常どおり`handleSpotSelect`に流れるため、作成モード中の追加確認にもそのまま繋がる。**重ね表示のピンも同じ流れ**(`handleOverlaySpotSelect`が`overlayDisplayedRef`=種別ごとの描画中スポットで引き直し、選ぶと`openOverlaySpot`=読み取り専用の詳細/作成モードの追加確認へ流れる)。**一覧の行の名前・ランク・カテゴリはそのスポットが属する種別の設定で解決する**(本体の設定で描くと名前が出ない・ランクのラベルがずれる)。**一覧は横幅を`max-w-sm`に留める一方、高さは`max-h-[85vh]`まで使う**(重なりが数十件になる種別があるため、件数が多いときに画面の高さいっぱいまで伸ばし、はみ出す分だけ一覧側をスクロールさせる)
485+
- **一覧から詳細へ進んだら、詳細を閉じたときに一覧へ戻す**(`stackReturn`に開く前の`stack`を退避し、`SpotDetailModal``onClose`で復元する。本体・重ね表示の両方)。**地図から一覧を開き直せないため** —— 重なっている相方を続けて見たいのに、詳細を閉じるとピンを押し直すところからやり直しになる。一覧を✕や背景で閉じたときは`stackReturn`も消す(そこで見終わっているので、次に別の地点を開いたときに前の一覧へ戻ってはいけない)
486+
- 一覧の見出しの下に**簡単な住所**を出す(`stackAddress`。先頭のスポットの座標で`/api/geocode/reverse`を引く。同じ地点に積まれているので1件引けば足りる)。同じ名前のスポットが並ぶことがあり、どこの地点を開いたのかが件数だけでは分からないため。**座標をキーにメモリへ残して引き直さない**(Nominatimは1秒1回の利用条件があり、一覧を開くたびに叩くと超えてしまう)。引けなかったときは行ごと出さない
485487

486488
座標を少しずらして描く案は採らない(位置を偽ることになるため)。
487489

app/api/geocode/reverse/route.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ interface NominatimReverseResult {
99
state?: string;
1010
province?: string;
1111
county?: string;
12+
city?: string;
13+
town?: string;
14+
village?: string;
15+
suburb?: string;
16+
city_district?: string;
1217
"ISO3166-2-lvl4"?: string;
1318
};
1419
}
@@ -46,6 +51,33 @@ function resolveRegion(
4651
return address?.state ?? address?.province ?? address?.county ?? null;
4752
}
4853

54+
/**
55+
* 画面に出す「簡単な住所」。都道府県(国外は国+州)と市区町村までで、番地は出さない ——
56+
* **同じ地点に重なったスポットの一覧で「ここはどこか」を1行で言う**ためのもので、
57+
* 建物名や番地まで出すと行が長くなるだけで用を成さない。
58+
* zoom=14で引いているので、そもそも番地は返ってこない。
59+
*/
60+
function resolveShortAddress(
61+
address: NominatimReverseResult["address"],
62+
scope: string
63+
): string | null {
64+
const muni =
65+
address?.city ?? address?.town ?? address?.village ?? address?.county ?? null;
66+
const ward = address?.city_district ?? null;
67+
const parts =
68+
scope === "jp"
69+
? [resolveJpPrefecture(address), muni, ward]
70+
: [address?.country, address?.state ?? address?.province, muni];
71+
// 「東京都東京都」のような重複を避けるため、前の部分に含まれる語は落とす
72+
const out: string[] = [];
73+
for (const part of parts) {
74+
if (!part) continue;
75+
if (out.some((p) => p.includes(part) || part.includes(p))) continue;
76+
out.push(part);
77+
}
78+
return out.length > 0 ? out.join(scope === "jp" ? "" : ", ") : null;
79+
}
80+
4981
export async function GET(request: Request) {
5082
const userId = await getCurrentUserId();
5183
if (!userId) {
@@ -86,6 +118,10 @@ export async function GET(request: Request) {
86118
const address = result.address ?? {};
87119

88120
return NextResponse.json({
89-
data: { region: resolveRegion(address, scope) },
121+
data: {
122+
region: resolveRegion(address, scope),
123+
// 画面に出す用の短い住所(スポットの登録には使わない)
124+
address: resolveShortAddress(address, scope),
125+
},
90126
});
91127
}

components/MapView.tsx

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,6 +1645,16 @@ export default function MapView({
16451645
ids: string[];
16461646
overlayTypeKey: string | null;
16471647
} | null>(null);
1648+
/** 一覧から詳細へ進んだときの戻り先。詳細を閉じると一覧に戻す ——
1649+
* 重なったピンは地図から開き直せないので、閉じたら選ぶところからやり直しになる */
1650+
const [stackReturn, setStackReturn] = useState<{
1651+
ids: string[];
1652+
overlayTypeKey: string | null;
1653+
} | null>(null);
1654+
/** 「この地点のスポット」に出す簡単な住所。座標をキーに覚えておき、
1655+
* 同じ地点を開き直したときにNominatimへ問い合わせ直さない(1秒1回の利用条件があるため) */
1656+
const [stackAddress, setStackAddress] = useState<string | null>(null);
1657+
const stackAddressCacheRef = useRef<Map<string, string>>(new Map());
16481658
/** いま地図に出しているスポット(絞り込み後)。ピンのタップ処理は地図の
16491659
* レイヤー生成時に一度だけ束縛されるので、最新の一覧はrefで参照する */
16501660
const displayedSpotsRef = useRef<Spot[]>([]);
@@ -3482,6 +3492,38 @@ export default function MapView({
34823492
? spotTypes.find((t) => t.key === returnTypeKey) ?? null
34833493
: null;
34843494

3495+
// 「この地点のスポット」を開いたら、その座標の簡単な住所を1回だけ引く。
3496+
// **一覧の1件目の座標**で引く(同じ地点に積まれているのでどれでも同じ)。
3497+
// 失敗しても黙って出さない —— 住所は補助の情報で、無くても一覧は読める
3498+
useEffect(() => {
3499+
if (!stack) {
3500+
setStackAddress(null);
3501+
return;
3502+
}
3503+
const spot = (stack.overlayTypeKey ? overlaySpotById : spotById).get(stack.ids[0]);
3504+
if (!spot) return;
3505+
const key = `${spot.lat.toFixed(6)},${spot.lng.toFixed(6)}`;
3506+
const cached = stackAddressCacheRef.current.get(key);
3507+
if (cached !== undefined) {
3508+
setStackAddress(cached);
3509+
return;
3510+
}
3511+
let alive = true;
3512+
api.geocode
3513+
.reverse(spot.lat, spot.lng, regionScope ?? DEFAULT_REGION_SCOPE)
3514+
.then(({ data }) => {
3515+
const address = data?.address ?? null;
3516+
if (address) stackAddressCacheRef.current.set(key, address);
3517+
if (alive) setStackAddress(address);
3518+
})
3519+
.catch(() => {
3520+
// 通信に失敗しても一覧は読めるので黙って住所だけ出さない
3521+
});
3522+
return () => {
3523+
alive = false;
3524+
};
3525+
}, [stack, spotById, overlaySpotById, regionScope]);
3526+
34853527
return (
34863528
<div
34873529
className={`relative ${
@@ -4527,7 +4569,14 @@ export default function MapView({
45274569
readOnly
45284570
allowVisitRecording
45294571
allowPlanList
4530-
onClose={() => setOverlayDetailSpotId(null)}
4572+
onClose={() => {
4573+
setOverlayDetailSpotId(null);
4574+
// 一覧から開いていたら戻す
4575+
if (stackReturn) {
4576+
setStack(stackReturn);
4577+
setStackReturn(null);
4578+
}
4579+
}}
45314580
onVisitChange={loadVisits}
45324581
// 重ね表示スポットを現在の種別のリストへ追加したら、経路表示中のリストの
45334582
// 線にも反映されるようリスト一覧を取り直す
@@ -4558,7 +4607,10 @@ export default function MapView({
45584607
return (
45594608
<div
45604609
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
4561-
onClick={() => setStack(null)}
4610+
onClick={() => {
4611+
setStack(null);
4612+
setStackReturn(null);
4613+
}}
45624614
>
45634615
{/* 横幅は一覧に必要な分だけ。件数が多いと縦に伸びるので、画面の高さいっぱいまで
45644616
使い、はみ出す分だけ一覧側をスクロールさせる */}
@@ -4567,14 +4619,24 @@ export default function MapView({
45674619
onClick={(e) => e.stopPropagation()}
45684620
>
45694621
<div className="flex shrink-0 items-center justify-between border-b px-4 py-3">
4570-
<h2 className="text-sm font-semibold">
4571-
この地点のスポット({stack.ids.length}件)
4572-
</h2>
4622+
<div className="min-w-0">
4623+
<h2 className="text-sm font-semibold">
4624+
この地点のスポット({stack.ids.length}件)
4625+
</h2>
4626+
{/* 簡単な住所。同じ地点に積まれているので1つだけ出す
4627+
(引けなかったときは行ごと出さない) */}
4628+
{stackAddress && (
4629+
<p className="truncate text-xs text-slate-500">{stackAddress}</p>
4630+
)}
4631+
</div>
45734632
<button
45744633
type="button"
4575-
className="text-slate-400 hover:text-slate-600"
4634+
className="shrink-0 text-slate-400 hover:text-slate-600"
45764635
aria-label="閉じる"
4577-
onClick={() => setStack(null)}
4636+
onClick={() => {
4637+
setStack(null);
4638+
setStackReturn(null);
4639+
}}
45784640
>
45794641
45804642
</button>
@@ -4589,6 +4651,8 @@ export default function MapView({
45894651
type="button"
45904652
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-slate-50"
45914653
onClick={() => {
4654+
// 詳細を閉じたらこの一覧へ戻す(地図からは開き直せない)
4655+
setStackReturn(stack);
45924656
setStack(null);
45934657
if (overlayTypeKey) openOverlaySpot(id);
45944658
else handleSpotSelect(id);
@@ -4635,7 +4699,14 @@ export default function MapView({
46354699
<SpotDetailModal
46364700
spotId={detailSpotId}
46374701
spots={spots}
4638-
onClose={() => setDetailSpotId(null)}
4702+
onClose={() => {
4703+
setDetailSpotId(null);
4704+
// 一覧から開いていたら戻す
4705+
if (stackReturn) {
4706+
setStack(stackReturn);
4707+
setStackReturn(null);
4708+
}
4709+
}}
46394710
onVisitChange={loadVisits}
46404711
// 既存の訪問予定リストへの追加をリスト一覧へ反映する(経路表示中の
46414712
// リストに追加した場合、地図の紫の経路も引き直される)

lib/api-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,9 @@ export const api = {
245245
request<{ name: string; lat: number; lng: number }[]>(
246246
`/api/geocode?q=${encodeURIComponent(q)}&scope=${encodeURIComponent(scope)}`
247247
),
248+
// regionはスポット登録用、addressは画面に出す短い住所(都道府県+市区町村まで)
248249
reverse: (lat: number, lng: number, scope: string) =>
249-
request<{ region: string | null }>(
250+
request<{ region: string | null; address: string | null }>(
250251
`/api/geocode/reverse?lat=${lat}&lng=${lng}&scope=${encodeURIComponent(scope)}`
251252
),
252253
},

0 commit comments

Comments
 (0)