Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/components/map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
MAP_MIN_ZOOM,
MAP_PAN_RESTRICTION,
normalizeGooglePlaceResourceId,
panToWithinWorld,
readDestinationLatLngFromSession,
viewportSearchRadiusMetersFromBounds,
writeDestinationLatLngToSession,
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -202,7 +203,7 @@ function MapPreventHorizontalWrap() {

const last = lastValidCenterRef.current;
if (last) {
map.panTo(last);
panToWithinWorld(map, last);
}
});

Expand Down
45 changes: 41 additions & 4 deletions src/components/map/RouteViewCameraController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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(<RouteViewCameraController camera={camera} locations={locations}
async function render(focusedItemId: number, locations: MapRouteView["locations"], cam = camera) {
await act(async () => root.render(<RouteViewCameraController camera={cam} locations={locations}
focusedItemId={focusedItemId} expanded={false} candidate={null} />));
}

Expand All @@ -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);
});
74 changes: 62 additions & 12 deletions src/components/map/RouteViewCameraController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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가 가리는 지도 아래쪽 높이 */
Expand All @@ -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`이 오도록 옮긴다.
Expand All @@ -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<T extends google.maps.LatLngLiteral>(
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 */
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions src/lib/maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/";
Expand Down
Loading