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
46 changes: 19 additions & 27 deletions src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
import type { PlanPlace } from "@/lib/plan/types";
import { cn } from "@/lib/utils";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store";
import {
PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE,
planPlaceFocusTarget,
usePlanMapFocusStore,
} from "@/stores/plan-map-focus-store";

import { PlanItemMemoEditor, PlanItemMemoReadOnly } from "./PlanItemMemoForm";
import { PlanItemTimeEditor } from "./PlanItemTimeForm";
Expand Down Expand Up @@ -80,9 +84,10 @@ export function PlanPlaceCard({
s.focus.scheduleId === focusScheduleId &&
s.focus.itemId === place.itemId,
);
/** 지도 핀으로 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */
const focusSeqFromMap = usePlanMapFocusStore((s) =>
s.focus?.source === "map" &&
/** 카드가 아닌 곳(지도 핀·장소 이동 스테퍼)에서 고른 경우에만 값이 있다 — 그때 일정 목록을 이 카드로 스크롤한다 */
const focusSeqToReveal = usePlanMapFocusStore((s) =>
s.focus !== null &&
s.focus.source !== "card" &&
s.focus.roomId === focusRoomId &&
s.focus.scheduleId === focusScheduleId &&
s.focus.itemId === place.itemId
Expand All @@ -91,10 +96,10 @@ export function PlanPlaceCard({
);
const articleRef = useRef<HTMLElement>(null);
useEffect(() => {
if (focusSeqFromMap === null) return;
if (focusSeqToReveal === null) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
articleRef.current?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" });
}, [focusSeqFromMap]);
}, [focusSeqToReveal]);
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const blockCardDragRef = useRef(false);
const [thumbnailRef, thumbnailInViewport] = useInViewport<HTMLElement>();
Expand Down Expand Up @@ -202,34 +207,20 @@ export function PlanPlaceCard({
setSelectedPlace(null);
return;
}
const loc = place.location;
const hasLocation =
loc != null &&
typeof loc.lat === "number" &&
typeof loc.lng === "number" &&
Number.isFinite(loc.lat) &&
Number.isFinite(loc.lng);
const target = planPlaceFocusTarget(place, focusRoomId, focusScheduleId, "card");
if (!target) return;
// 일정에 넣은 장소는 상세를 보려는 게 아니라 위치를 보려는 것 — 상세(조회 포함) 대신 카드·지도 핀만 강조한다
setSelectedPlace(null);
focusPlace({
roomId: focusRoomId,
scheduleId: focusScheduleId,
itemId: place.itemId,
googlePlaceId: place.googlePlaceId?.trim() || null,
location: hasLocation ? { lat: loc.lat, lng: loc.lng } : null,
source: "card",
});
if (!hasLocation) toast.info("지도에 표시할 위치 정보가 없어요.");
focusPlace(target);
if (!target.location) toast.info(PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE);
},
[
clearFocus,
focusPlace,
focusRoomId,
focusScheduleId,
focused,
place.googlePlaceId,
place.itemId,
place.location,
place,
setSelectedPlace,
],
);
Expand Down Expand Up @@ -293,7 +284,7 @@ export function PlanPlaceCard({
aria-label={hasTime ? `시간 ${timeRange} 수정` : "시간 설정"}
className={cn(PLAN_PLACE_CARD_TW.actionButton, "tabular-nums")}
>
<TimeClockIcon size={16} className={PLAN_PLACE_CARD_TW.actionIcon} />
<TimeClockIcon size={16} className={hasTime ? PLAN_PLACE_CARD_TW.actionIcon : PLAN_PLACE_CARD_TW.actionIconEmpty} />
<span>{timeRange || "시간"}</span>
</button>
) : null;
Expand Down Expand Up @@ -429,7 +420,8 @@ export function PlanPlaceCard({
label="비용"
// 금액이 길면 메모 버튼 앞에서 말줄임되도록 공통 버튼의 shrink-0를 푼다
className={cn(PLAN_PLACE_CARD_TW.actionButton, "min-w-0 shrink")}
icon={<CoinIcon size={16} className={PLAN_PLACE_CARD_TW.actionIcon} />}
// 금액이 있을 때만 파랑 — 버튼의 `data-filled`로 바꾼다
icon={<CoinIcon size={16} className={cn(PLAN_PLACE_CARD_TW.actionIconEmpty, "group-data-filled:text-primary")} />}
/>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
overflow: auto !important; /* NOSONAR: S9423 — Google Maps의 인라인 overflow:scroll에 의한 불필요한 스크롤바를 제거한다. */
}

/* PC 지도 버튼은 지도 끝에서 아래·좌우 모두 24px — 확대·축소(+/−)의 아래는 저작권 줄과 기본 여백으로 이미 24px이라 오른쪽만 넓힌다. */
[data-map-controls-inset] .gm-bundled-control {
margin-right: 24px !important; /* NOSONAR: S9423 — Google Maps가 주입하는 인라인 margin(10px)을 재정의한다. */
}

@layer base {
html {
@apply font-sans;
Expand Down
3 changes: 2 additions & 1 deletion src/components/expenses/ExpenseProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ export function ExpenseEntryButton({
aria-label={`${scopeLabel} ${context.list.isSuccess && !scoped.length ? "비용 추가" : "비용 목록 열기"}`}
aria-describedby={scheduleItemId === undefined ? undefined : summaryId}
aria-haspopup="dialog"
data-filled={context.list.isSuccess && scoped.length > 0 ? "" : undefined}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
Expand All @@ -457,7 +458,7 @@ export function ExpenseEntryButton({
context.openScope(scope);
}
}}
className={cn(className, "min-w-0 max-w-full text-left", "disabled:cursor-not-allowed disabled:opacity-50")}
className={cn(className, "group min-w-0 max-w-full text-left", "disabled:cursor-not-allowed disabled:opacity-50")}
>
{icon}<span id={scheduleItemId === undefined ? undefined : summaryId} className="min-w-0 truncate" title={scheduleItemId === undefined ? undefined : summaryLabel}>{summaryLabel}</span>
</button>
Expand Down
77 changes: 59 additions & 18 deletions src/components/map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
MAP_MAX_ZOOM,
MAP_MIN_ZOOM,
MAP_PAN_RESTRICTION,
moveMapTo,
normalizeGooglePlaceResourceId,
panToWithinWorld,
readDestinationLatLngFromSession,
Expand All @@ -35,6 +36,7 @@ import {
readRoomMapViewport,
writeRoomMapViewport,
} from "@/lib/map-room-viewport-storage";
import { MAP_DETAIL_PANEL_COVER_WIDTH_PX } from "@/lib/layout-tokens";
import { isPlanPath } from "@/lib/layout/mainChromeLayoutWidth";
import { isMobileMapPathname } from "@/lib/mobile-view";
import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius";
Expand Down Expand Up @@ -62,6 +64,8 @@ import { MapDiscoverToolbar } from "./MapDiscoverToolbar";
import { RouteViewCameraController } from "./RouteViewCameraController";
import { MapDiscoverPlaces } from "./MapDiscoverPlaces";
import { PlanItineraryMapRoutes, type PlanItineraryRouteDay } from "./PlanItineraryMapRoutes";
import { PlanRouteStepper } from "./PlanRouteStepper";
import { useWebPlanRouteMode } from "./useWebPlanRouteMode";
import type { OpenValue, RatingValue } from "./map-filters";

/** 모바일 경로 보기 — 고른 일차만 그리고, 카드 선택에 맞춰 지도를 옮긴다 */
Expand Down Expand Up @@ -93,7 +97,10 @@ export type MapRouteView = PlanItineraryRouteDay & {

// ─── 웹 일정에서 고른 장소로 지도 이동 ─────────────────────────────────────────

/** 일정 카드·핀으로 고르면 줌은 그대로 두고 그 장소로 부드럽게 옮긴다(모바일 경로 보기의 카드 넘김과 같은 이동) */
/**
* 일정 카드·핀·장소 이동 스테퍼로 고르면 줌은 그대로 두고 그 장소로 부드럽게 옮긴다(모바일 경로 보기의 카드 넘김과 같은 이동).
* 지도 핀으로 고르면 함께 연 상세(`SelectedPlaceController`)도 같은 곳으로 옮기는데, 같은 곳으로 가는 이동은 하나로 이어진다
*/
function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null }>) {
const map = useMap();
const rid = typeof roomId === "string" ? roomId.trim() : "";
Expand All @@ -103,7 +110,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null

useEffect(() => {
if (!map || focusSeq === null || lat === undefined || lng === undefined) return;
panToWithinWorld(map, { lat, lng });
return moveMapTo(map, { lat, lng });
}, [map, focusSeq, lat, lng]);

return null;
Expand All @@ -113,6 +120,7 @@ function PlanFocusCameraController({ roomId }: Readonly<{ roomId: string | null

function SelectedPlaceController() {
const map = useMap();
const { isMobileDevice } = useMobileView();
const { selectedPlace, placeSelectionCameraRef } = useSelectedPlace();

useEffect(() => {
Expand All @@ -121,17 +129,24 @@ function SelectedPlaceController() {
placeSelectionCameraRef.current = "full";

if (mode === "none") return;
panToWithinWorld(map, selectedPlace.location);
if (mode === "full") {
map.setZoom(16);
const zoom = mode === "full" ? 16 : undefined;
// 모바일은 아직 구글 이동(`panTo`)을 그대로 쓴다
if (isMobileDevice) {
panToWithinWorld(map, selectedPlace.location);
if (zoom !== undefined) map.setZoom(zoom);
return;
}
}, [map, selectedPlace?.location, placeSelectionCameraRef]);
moveMapTo(map, selectedPlace.location, { zoom });
}, [map, isMobileDevice, selectedPlace?.location, placeSelectionCameraRef]);

return null;
}

const VIEWPORT_PERSIST_DEBOUNCE_MS = 400;

/** 오른쪽 아래 확대·축소(+/−) 자리 — 지도 끝 24 + 버튼 40 + 간격 12. 장소 이동 스테퍼가 이보다 오른쪽으로 가지 않는다 */
const ZOOM_CONTROL_CLEARANCE_PX = 24 + 40 + 12;

/** bootstrap 복원 직후 Zustand `mapCenter`·줌을 검색/일정 UI와 맞춤 */
function MapBootstrapSync({
ready,
Expand Down Expand Up @@ -185,9 +200,13 @@ function MapBootstrapSync({
return null;
}

/** 가로 월드 래핑(지도 타일 무한 복제) 시 마지막 유효 중심으로 되돌림 */
/**
* 가로 월드 래핑(지도 타일 무한 복제) 시 마지막 유효 중심으로 되돌림.
* 되돌리는 동안에도 `center_changed`가 계속 오지만 같은 곳으로 가는 이동은 새로 시작하지 않는다(`moveMapTo`)
*/
function MapPreventHorizontalWrap() {
const map = useMap();
const { isMobileDevice } = useMobileView();
const lastValidCenterRef = useRef<google.maps.LatLngLiteral | null>(null);

useEffect(() => {
Expand All @@ -208,9 +227,10 @@ function MapPreventHorizontalWrap() {
}

const last = lastValidCenterRef.current;
if (last) {
panToWithinWorld(map, last);
}
if (!last) return;
// 모바일은 아직 구글 이동(`panTo`)을 그대로 쓴다
if (isMobileDevice) panToWithinWorld(map, last);
else moveMapTo(map, last);
});

const c = map.getCenter();
Expand All @@ -221,7 +241,7 @@ function MapPreventHorizontalWrap() {
return () => {
google.maps.event.removeListener(listener);
};
}, [map]);
}, [map, isMobileDevice]);

return null;
}
Expand Down Expand Up @@ -319,8 +339,7 @@ function MapSearchResultPins() {
if (!map || isMobileDevice || pinsFocus !== "search" || kind !== "whole" || status !== "done" || !items.length || usePlaceSearchStore.getState().selectedId || fittedItemsRef.current === items) return;
fittedItemsRef.current = items;
if (items.length === 1) {
panToWithinWorld(map, items[0].location);
map.setZoom(16);
moveMapTo(map, items[0].location, { zoom: 16 });
} else {
const bounds = new google.maps.LatLngBounds();
items.forEach((item) => bounds.extend(item.location));
Expand Down Expand Up @@ -402,9 +421,10 @@ export default function TripMap({
const isPlanPage = isPlanPath(pathname ?? "") || isMobileMapPathname(pathname ?? "");
const planStopPlaceIds = usePlanItineraryStopNormalizedPlaceIds(isPlanPage);

const { setSelectedPlace } = useSelectedPlace();
const { selectedPlace, setSelectedPlace } = useSelectedPlace();
const { isMobileDevice } = useMobileView();
const clearPlanFocus = usePlanMapFocusStore((s) => s.clearFocus);
const planRouteMode = useWebPlanRouteMode();
const setMapCamera = useMapCenterStore((s) => s.setMapCamera);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
null,
Expand Down Expand Up @@ -557,7 +577,7 @@ export default function TripMap({
);

return (
<div className="relative h-full min-h-0 w-full min-w-0">
<div className="relative h-full min-h-0 w-full min-w-0" data-map-controls-inset={isMobileDevice ? undefined : ""}>
<div className="absolute inset-0 min-h-0 overflow-hidden">
{!bootstrap.ready ? (
<div
Expand Down Expand Up @@ -612,18 +632,21 @@ export default function TripMap({

{!routeView && <SelectedPlaceMarker />}

{/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다 */}
{/* PC는 확대·축소(+/−) 바로 위 — 같은 오른쪽 아래 컨트롤 자리에 넣으면 지도가 +/− 위로 쌓아 준다.
오른쪽은 +/−와 같이 지도 끝에서 24px(globals.css `[data-map-controls-inset]`) */}
{routeView || isMobileDevice ? null : (
<MapControl position={ControlPosition.RIGHT_BOTTOM}>
<div className="mr-2.5">{bookmarkToggle}</div>
<div className="mr-6">{bookmarkToggle}</div>
</MapControl>
)}
</GoogleMap>
)}
</div>

{/* 웹 동선보기는 숨기기만 한다 — 상세 패널 위치를 계속 재서 필터 한 줄 아래(최솟값)에 두고, 풀면 고른 카테고리·필터 그대로 보인다 */}
{routeView ? null : (
<MapDiscoverToolbar
hidden={planRouteMode}
selectedCategoryId={selectedCategoryId}
onSelectCategory={setSelectedCategoryId}
rating={rating}
Expand All @@ -633,9 +656,27 @@ export default function TripMap({
/>
)}

{/* 웹 동선보기 장소 이동 — 지도 아래 가운데, 다른 지도 버튼처럼 아래 24px.
상세 패널이 열리면 패널 오른쪽 끝~지도 오른쪽 끝의 가운데로 옮긴다.
오른쪽 칸은 +/− 자리만큼 줄어들지 않아, 지도가 좁아지면 +/−에 밀려 왼쪽으로 넘치고(justify-end)
z-[16]이 상세 패널(`MapWithDetailPanel` z-20)보다 낮아 패널 뒤로 가려진다 */}
{bootstrap.ready && !routeView && !isMobileDevice && isPlanPage ? (
<div
style={{
paddingLeft: selectedPlace ? MAP_DETAIL_PANEL_COVER_WIDTH_PX : 0,
gridTemplateColumns: `minmax(0, 1fr) auto minmax(${ZOOM_CONTROL_CLEARANCE_PX}px, 1fr)`,
}}
className="pointer-events-none absolute inset-x-0 bottom-6 z-[16] grid justify-end transition-[padding] duration-300 ease-out"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 재검색 버튼과 스테퍼의 위치를 분리해 주세요

카테고리 검색을 완료한 뒤 검색 중심에서 충분히 떨어진 일정 카드를 선택하면 두 버튼이 함께 표시됩니다. PlanPlaceCard의 setSelectedPlace(null); focusPlace(target);는 상세만 닫고 카테고리와 discoverSnapshot을 유지합니다. 이후 지도 중심이 이동하면 usePlacesSearchHereVisible의 재검색 조건이 성립합니다.

이때 스테퍼는 하단 중앙의 bottom-6 z-[16]에 놓이는데, SearchHereFloatingButton.tsx:17도 같은 하단 중앙에 bottom-6 left-1/2 z-[17]로 표시됩니다. 더 높은 재검색 버튼이 순서 표시와 겹치는 조작 영역을 가려, 장소 이동을 누르려다 검색이 실행될 수 있습니다. 두 기능이 함께 활성화되어도 표시·클릭 영역이 겹치지 않도록 배치해 주세요.

>
<div className="col-start-2">
<PlanRouteStepper roomId={currentRoomId} />
</div>
</div>
) : null}

{!bootstrap.ready || routeView ? null : (
<>
<MapSearchHereButton discoverCategoryId={selectedCategoryId} />
{planRouteMode ? null : <MapSearchHereButton discoverCategoryId={selectedCategoryId} />}
{isMobileDevice ? (
<div className="pointer-events-none absolute bottom-6 left-4 z-[16] flex flex-col items-center gap-3">
{onOpenRouteView ? (
Expand Down
4 changes: 4 additions & 0 deletions src/components/map/MapDiscoverToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type { OpenValue, RatingValue } from "./map-filters";
import { mapToolbarPanelMotion } from "./map-toolbar-motion";

type MapDiscoverToolbarProps = {
/** 웹 동선보기에서 숨김 — 상세 패널은 최솟값(필터 한 줄 아래) 자리로 간다 */
hidden?: boolean;
selectedCategoryId: string | null;
onSelectCategory: (id: string | null) => void;
rating: RatingValue;
Expand All @@ -31,6 +33,7 @@ type MapDiscoverToolbarProps = {
};

export function MapDiscoverToolbar({
hidden = false,
selectedCategoryId,
onSelectCategory,
rating,
Expand All @@ -51,6 +54,7 @@ export function MapDiscoverToolbar({
return (
<div
ref={setToolbarRef}
hidden={hidden}
className={cn(
"pointer-events-none absolute left-0 right-[312px] top-3 flex min-h-12 max-w-full flex-col justify-center overflow-visible px-4 mobile:right-0 mobile:block mobile:px-5",
ratingDropdownOpen ? MAP_TOOLBAR_ELEVATED_Z_CLASS : MAP_TOOLBAR_Z_CLASS,
Expand Down
7 changes: 6 additions & 1 deletion src/components/map/MapPlaceSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { MobileSearchResultCarousel } from "./MobileSearchResultCarousel";
import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
import { useMapPinsFocusStore } from "@/stores/map-pins-focus-store";

export function MapPlaceSearch({ mobile }: Readonly<{ mobile: boolean }>) {
export function MapPlaceSearch({ mobile, hidden = false }: Readonly<{
mobile: boolean;
/** PC 검색창 숨김(웹 동선보기) — 입력값·자동완성을 지키려고 언마운트하지 않는다 */
hidden?: boolean;
}>) {
const { confirm, invalidate, clearSearch, scopeKey } = useMapPlaceSearch(mobile);
const initialQuery = useSearchParams().get("q")?.trim() ?? "";
const status = usePlaceSearchStore((s) => s.status);
Expand Down Expand Up @@ -59,6 +63,7 @@ export function MapPlaceSearch({ mobile }: Readonly<{ mobile: boolean }>) {
) : (
<div
data-tutorial-target="search"
hidden={hidden}
className="absolute right-4 top-[18px] z-30"
>
<MapPlaceSearchInput
Expand Down
Loading