From 99f218972964fb68f7fed6e2937e968769356ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Mon, 12 Oct 2026 17:48:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(map):=EB=A7=B5=20=EB=81=9D=EC=97=90=20?= =?UTF-8?q?=EB=A7=89=ED=9E=88=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/Map.tsx | 7 ++++--- src/lib/maps.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index fc98210c..c012f57c 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -19,6 +19,7 @@ import { MAP_MIN_ZOOM, MAP_PAN_RESTRICTION, normalizeGooglePlaceResourceId, + panToWithinWorld, readDestinationLatLngFromSession, viewportSearchRadiusMetersFromBounds, writeDestinationLatLngToSession, @@ -96,7 +97,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null useEffect(() => { if (!map || focusSeq === null || lat === undefined || lng === undefined) return; - map.panTo({ lat, lng }); + panToWithinWorld(map, { lat, lng }); }, [map, focusSeq, lat, lng]); return null; @@ -114,7 +115,7 @@ function SelectedPlaceController() { placeSelectionCameraRef.current = "full"; if (mode === "none") return; - map.panTo(selectedPlace.location); + panToWithinWorld(map, selectedPlace.location); if (mode === "full") { map.setZoom(16); } @@ -202,7 +203,7 @@ function MapPreventHorizontalWrap() { const last = lastValidCenterRef.current; if (last) { - map.panTo(last); + panToWithinWorld(map, last); } }); diff --git a/src/lib/maps.ts b/src/lib/maps.ts index 54e31569..5c70a3de 100644 --- a/src/lib/maps.ts +++ b/src/lib/maps.ts @@ -148,6 +148,23 @@ export const MAP_PAN_RESTRICTION: google.maps.MapRestriction = { strictBounds: true, }; +/** + * 가로 래핑을 막은 지도({@link MAP_PAN_RESTRICTION})에서 `target`으로 옮긴다. + * `panTo`는 날짜변경선(경도 ±180)을 넘는 쪽이 더 가까우면 그쪽으로 가다 경계에 막혀 바다 한가운데에 멈추므로 + * (예: 샌프란시스코 → 도쿄), 그때만 `setCenter`로 바로 옮긴다. 이만큼 먼 이동은 `panTo`도 애니메이션 없이 건너뛴다. + */ +export function panToWithinWorld( + map: google.maps.Map, + target: google.maps.LatLngLiteral, +): void { + const center = map.getCenter(); + if (center && Math.abs(target.lng - center.lng()) > 180) { + map.setCenter(target); + return; + } + map.panTo(target); +} + /** Google Places 리소스 id(`places/…`) 접두를 제거한 레거시 id */ export function normalizeGooglePlaceResourceId(id: string): string { const prefix = "places/"; From 1e950503e20d1ce29d2de6b1e75ad05e2607acce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Mon, 12 Oct 2026 18:37:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(map):=EB=A8=BC=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=ED=95=9C=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=8B=B4=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/RouteViewCameraController.test.tsx | 45 ++++++++++- .../map/RouteViewCameraController.tsx | 74 ++++++++++++++++--- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/components/map/RouteViewCameraController.test.tsx b/src/components/map/RouteViewCameraController.test.tsx index 2935ba9d..ff29aa6d 100644 --- a/src/components/map/RouteViewCameraController.test.tsx +++ b/src/components/map/RouteViewCameraController.test.tsx @@ -4,7 +4,18 @@ import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { MapRouteView } from "./Map"; -const map = vi.hoisted(() => ({ panTo: vi.fn(), getZoom: () => 15 })); +const map = vi.hoisted(() => ({ + center: { lat: 37.5, lng: 127 }, + panTo: vi.fn(), + setCenter: vi.fn(), + setZoom: vi.fn(), + fitBounds: vi.fn(), + getZoom: () => 15, + getCenter() { + return { lat: () => this.center.lat, lng: () => this.center.lng }; + }, + getDiv: () => ({ clientWidth: 390, clientHeight: 700 }), +})); vi.mock("@vis.gl/react-google-maps", () => ({ useMap: () => map })); import { RouteViewCameraController } from "./RouteViewCameraController"; @@ -16,14 +27,15 @@ const camera: MapRouteView["camera"] = { kind: "pan", seq: 1 }; beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - map.panTo.mockClear(); + map.center = { lat: 37.5, lng: 127 }; + [map.panTo, map.setCenter, map.setZoom, map.fitBounds].forEach((fn) => fn.mockClear()); host = document.createElement("div"); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); }); -async function render(focusedItemId: number, locations: MapRouteView["locations"]) { - await act(async () => root.render( root.render()); } @@ -40,3 +52,28 @@ it("삭제 후 선택이 다음 장소로 바뀌면 같은 카메라 요청에 await render(2, [second]); expect(map.panTo).toHaveBeenCalledWith(expect.objectContaining({ lng: 128 })); }); + +const incheon = { itemId: 10, lat: 37.46, lng: 126.44 }; +const sanFrancisco = { itemId: 11, lat: 37.76, lng: -122.41 }; +const santaClara = { itemId: 12, lat: 37.32, lng: -121.99 }; +const losAngeles = { itemId: 13, lat: 34.05, lng: -118.24 }; + +it("날짜변경선을 넘는 쪽이 가까운 장소로는 경계에 막히지 않게 바로 옮긴다", async () => { + await render(11, [incheon, sanFrancisco]); + expect(map.panTo).not.toHaveBeenCalled(); + expect(map.setCenter).toHaveBeenCalledWith(expect.objectContaining({ lng: -122.41 })); +}); + +it("일차가 줌 9 한 화면에 안 들어가면 고른 장소 쪽 장소만 맞춘다", async () => { + await render(11, [incheon, sanFrancisco, losAngeles, santaClara], { kind: "fit", seq: 1 }); + expect(map.fitBounds).toHaveBeenCalledWith( + expect.objectContaining({ west: -122.41, east: -121.99 }), + expect.anything(), + ); +}); + +it("고른 장소 말고 함께 들어가는 장소가 없으면 그 장소를 가까이 보여 준다", async () => { + await render(11, [incheon, sanFrancisco, losAngeles], { kind: "fit", seq: 1 }); + expect(map.fitBounds).not.toHaveBeenCalled(); + expect(map.setZoom).toHaveBeenCalledWith(15); +}); diff --git a/src/components/map/RouteViewCameraController.tsx b/src/components/map/RouteViewCameraController.tsx index 598e6823..33229cf8 100644 --- a/src/components/map/RouteViewCameraController.tsx +++ b/src/components/map/RouteViewCameraController.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useMap } from "@vis.gl/react-google-maps"; +import { haversineMeters, panToWithinWorld } from "@/lib/maps"; import type { MapRouteView } from "./Map"; /** 접힌 카드·일차 탭·X가 가리는 지도 아래쪽 높이 */ @@ -11,6 +12,8 @@ const ROUTE_VIEW_EXPANDED_COVER_RATIO = 0.72; const ROUTE_VIEW_EDGE_PADDING = 40; /** 장소 하나만 볼 때(장소 하나뿐인 일차·일정 카드로 들어올 때)의 줌 — 주변 길이 보이는 정도 */ const ROUTE_VIEW_SINGLE_PLACE_ZOOM = 15; +/** 일차 전체를 볼 때 가장 넓게 줄이는 줌 — 이보다 줄여야 함께 보이는 장소는 빼고 고른 장소 쪽만 맞춘다(폰에서 사방 약 80km — 도심과 공항 정도) */ +const ROUTE_VIEW_FIT_MIN_ZOOM = 9; /** * 가린 영역을 뺀 나머지의 가운데에 `target`이 오도록 옮긴다. @@ -24,13 +27,51 @@ function panToVisibleCenter( zoom = map.getZoom(), ) { if (zoom === undefined || coveredPx <= 0) { - map.panTo(target); + panToWithinWorld(map, target); return; } const lngDegreesPerPx = 360 / (256 * 2 ** zoom); const latDegreesPerPx = lngDegreesPerPx * Math.cos((target.lat * Math.PI) / 180); // 지도 중심을 아래로 coveredPx/2만큼 내리면 target이 보이는 영역 가운데로 올라온다 - map.panTo({ lat: target.lat - (coveredPx / 2) * latDegreesPerPx, lng: target.lng }); + panToWithinWorld(map, { lat: target.lat - (coveredPx / 2) * latDegreesPerPx, lng: target.lng }); +} + +/** 줌 0의 메르카토르 세계 좌표(0~256px) */ +function toWorldPx({ lat, lng }: google.maps.LatLngLiteral) { + const sin = Math.min(Math.max(Math.sin((lat * Math.PI) / 180), -0.9999), 0.9999); + return { x: 256 * (0.5 + lng / 360), y: 256 * (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) }; +} + +/** + * `ROUTE_VIEW_FIT_MIN_ZOOM`에서 `widthPx`×`heightPx` 안에 함께 들어가는 장소 — `anchor`에서 가까운 순으로 채운다. + * 멀리 떨어진 일차(인천공항 → 샌프란시스코 등)를 통째로 맞추면 너무 넓어지거나 두 곳의 가운데(바다·다른 대륙)만 보이므로 + * 고른 장소 쪽만 남긴다. + */ +function placesFittingAround( + anchor: google.maps.LatLngLiteral, + locations: readonly T[], + widthPx: number, + heightPx: number, +) { + const scale = 2 ** ROUTE_VIEW_FIT_MIN_ZOOM; + const byDistance = [...locations].sort((a, b) => haversineMeters(anchor, a) - haversineMeters(anchor, b)); + const fitting: T[] = []; + let box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }; + for (const location of byDistance) { + const { x, y } = toWorldPx(location); + const next = { + minX: Math.min(box.minX, x), + maxX: Math.max(box.maxX, x), + minY: Math.min(box.minY, y), + maxY: Math.max(box.maxY, y), + }; + if (fitting.length > 0 && ((next.maxX - next.minX) * scale > widthPx || (next.maxY - next.minY) * scale > heightPx)) { + continue; + } + box = next; + fitting.push(location); + } + return fitting; } /** 지도 왼쪽·위쪽에서 `target`까지의 거리(px) — 지도를 아직 그리지 않았으면 null */ @@ -110,19 +151,28 @@ export function RouteViewCameraController({ panToVisibleCenter(map, toLatLng(target), coveredPx, ROUTE_VIEW_SINGLE_PLACE_ZOOM); return; } - if (locations.length === 1) { + const padding = { top: 56, right: ROUTE_VIEW_EDGE_PADDING, bottom: coveredPx + 16, left: ROUTE_VIEW_EDGE_PADDING }; + const { clientWidth, clientHeight } = map.getDiv(); + const anchor = target ?? locations[0]!; + const shown = placesFittingAround( + anchor, + locations, + clientWidth - padding.left - padding.right, + clientHeight - padding.top - padding.bottom, + ); + if (shown.length === 1) { map.setZoom(ROUTE_VIEW_SINGLE_PLACE_ZOOM); - panToVisibleCenter(map, toLatLng(locations[0]!), coveredPx, ROUTE_VIEW_SINGLE_PLACE_ZOOM); + panToVisibleCenter(map, toLatLng(anchor), coveredPx, ROUTE_VIEW_SINGLE_PLACE_ZOOM); return; } - const bounds = new google.maps.LatLngBounds(); - locations.forEach((l) => bounds.extend(toLatLng(l))); - map.fitBounds(bounds, { - top: 56, - right: ROUTE_VIEW_EDGE_PADDING, - bottom: coveredPx + 16, - left: ROUTE_VIEW_EDGE_PADDING, - }); + // `LatLngBounds.extend`는 날짜변경선을 넘는 쪽이 좁으면 그쪽 범위를 잡는데, 가로 래핑을 막은 지도에선 + // 경계에 막혀 한쪽만 보인다 — 넘지 않는 범위를 리터럴로 넘긴다 + const lats = shown.map((l) => l.lat); + const lngs = shown.map((l) => l.lng); + map.fitBounds( + { west: Math.min(...lngs), east: Math.max(...lngs), south: Math.min(...lats), north: Math.max(...lats) }, + padding, + ); }, [map, camera.kind, camera.seq, locations, focusedItemId, expanded]); return null;