diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 3f996e7..6f60bf2 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -474,3 +474,57 @@ WDS에 이름이 비슷한 `ProgressIndicator`가 실제로 존재해서(`제외 ### 구역 카드 배경이 화면 배경과 같은 색이라 라우트에 `background: "normal"`이 필요하다 카드 배경이 `Background/Normal/Alternative`(`#f7f7f8`)인데 `ScreenLayout` 기본 배경도 같은 값이라, handle을 안 주면 카드가 배경에 완전히 묻힌다(실제로 처음 렌더에서 카드가 안 보였다). Figma 화면 배경이 흰 면이라 `handle: { background: "normal" }`을 준다. + +## 사물함 구역별 칸 선택 화면(`2159:110753` A-1, `2159:109533` A-2, `2159:110174` A-1 실제사진) 구현 중 확정된 매핑 + +| WDS 컴포넌트 | 확인 경로 | 비고 | +|---|---|---| +| `Icon/Normal/Image` | A-1 Top Navigation 트레일링 사진 버튼 | `search_design_system`에서 WDS 라이브러리 `component_set`으로 확인(componentKey `ba5095aab91509506b1380975f260bdb0658567f`), 코드 `IconImage` | +| `Name=close, Thick=False` (`Icon/Normal/Close`) | 실제사진 모달 닫기 | 코드 `IconClose`를 WDS `IconButton size={24}`로 감쌌다 | +| `Icon/Normal/Reset` | Top Navigation 트레일링 새로고침 | 구역 선택 화면과 같다 — `IconReset` | +| `Action Area/Action Area` | 하단 선택 영역 | `ActionAreaButton` + `paddingBlock: "16px"` 보정(위 "Action Area 메인 버튼 높이") | + +칸(`Locker Cell`)·칸 묶음(`Zone Area`)·`Shelf Label`·범례·미니맵·`Locker Selector`·실제사진 모달은 전부 Stream 로컬이다. `search_design_system`에서 `Locker Cell`은 결과가 없었다. + +### 구역별 칸 배치는 서버 layout JSON을 렌더러로 그린다 + +구역마다 배치 컴포넌트를 두지 않고, 구역 상세 응답의 `layout`(블록 7종: `row`·`column`·`lockerGroup`·`label`·`area`·`text`·`shelfLabel`)을 `LockersLayoutRenderer`가 그린다. 타입과 A-1·A-2 목데이터는 `constants/lockersSectionDetails.ts`. 블록은 Figma 로컬 요소와 이렇게 대응한다. + +| 블록 | Figma | 코드 | +|---|---|---| +| `lockerGroup` | Zone Area + Locker Grid | `LockersLockerGrid`(`bordered`면 `Line/Solid/Alternative` 테두리 상자) | +| `label` | Direction Label·Aisle | `LockersMapLabel` — 세로면 한 글자씩 줄바꿈하고 단어 사이에 빈 줄 | +| `area` | Room Label·Zone Label·Stairs Area | `LockersMapArea` — 구역 선택 평면도와 같은 점선 상자 | +| `shelfLabel` | Shelf Label | `LockersShelfLabel` | + +크기는 `hug`·`fill`·px다. `fill`은 부모와 같은 축이면 남은 공간을 나누고(`flex-1`) 다른 축이면 부모에 맞춰 늘어난다(`self-stretch`). 칸 상태는 layout에 없고 `lockers`에서 `lockerNumber`로 찾는다. + +### 선택 불가 칸은 범례 SVG 하나로 그린다 + +Figma 범례의 선택 불가 칸(`2159:110819`)은 `Label/Assistive` 바탕 + `Label/Neutral` 대각선을 합친 15px SVG다. `preserveAspectRatio="none"`이라 28px 칸에 그대로 늘려도 모서리(2.29 → 4.28px)·선 굵기가 본문 칸(radius 4)과 맞아서, 본문 칸도 같은 파일(`locker-unavailable.svg`)을 쓴다. + +### 미니맵은 따로 그리지 않고 본문 배치를 `zoom`으로 줄였다 + +Figma 미니맵은 칸 8px·글자 3.5~4px로 하나하나 다시 그렸지만, 코드에서는 본문 칸 배치를 `zoom`으로 줄여 그린다. 배치를 두 벌 관리하지 않고, 4px 글자가 브라우저 최소 글자 크기에 걸리는 문제도 피한다. + +- 높이는 Figma대로 110px 고정, 폭은 배치 비율에 맞춘다(Figma 미니맵 폭 156~208px도 내용에 맞춘 값이다). 범례 앞 남은 폭을 넘으면 더 줄인다 +- 본문과 같은 1배 기준 폭으로 그려야 `fill` 상자 비율이 본문과 같다 +- 확대·축소가 있어서 모든 구역에 띄운다. 검은 테두리(`Highlight Overlay`)는 보이는 영역의 가로·세로 비율을 따라가고(확대하면 작아진다), 스크롤이 없으면 숨긴다 + +### 칸 배치 확대·축소는 CSS `zoom`으로 한다 + +`useLockersPinchZoom` — 핀치·Ctrl+휠(트랙패드 핀치), 1배에서 가운데부터, 최소는 배치 전체가 화면 폭에 들어오는 배율, 최대 3배(CGV 좌석 선택 참고). `transform: scale`은 스크롤 영역 크기에 반영되지 않아 `zoom`을 쓴다. 확대하는 래퍼의 최소 폭을 `%`로 주면 배율과 상관없이 화면 폭으로 계산돼 `fill` 상자가 비율대로 커지지 않으므로 px로 준다. 배치 영역에 `touch-pan-x touch-pan-y`를 줘서 두 손가락 동작이 페이지 확대로 가지 않게 한다 — 앱 WebView(iOS·안드로이드)에서는 실기기 확인이 필요하다. + +### Figma 아티팩트 — A-1 `Shelf Label` 높이가 1px이다 + +A-1 본문의 `Zone Grid Row`(`2159:110823`)가 `h-px`라 `위쪽 칸/아래쪽 칸`이 칸 묶음 아래로 흘러내려 보인다. 미니맵(`2159:110761`)과 A-2(`2159:109622`)에서는 칸 묶음 높이에 맞춰 붙어 있어서, 칸 묶음 높이에 맞춘다 — layout의 `shelfLabel.height`(테두리 있는 3행 묶음 122, 칸만 있는 3행 묶음 96). + +### 사물함 선택 시(`2159:113197`) — 선택 상태는 구역 카드와 다르다 + +구역 카드(`LockersSectionCard`)는 선택되면 Blue/95 배경 + Primary 테두리지만, 사물함 칸은 **Primary/Normal 배경 + 흰 SemiBold 번호**다(`2159:113315`). 구역 카드 표현을 그대로 베끼면 틀린다. 하단 `Locker Selector`는 번호를 `Label 1/Normal - Bold` + Primary/Normal로 `B-25`처럼 쓰고, 버튼 문구는 `사물함 신청하기`다. 이름은 조합하지 않고 서버 `lockerLabel`을 그대로 쓴다. + +내 사물함(`isMine`)은 Figma에 상태가 없어서 임시로 `Status/Positive` 초록 테두리를 둔다(이미 신청한 칸이라 고를 수 없다). + +### 실제 사진 핀 + +서버는 사진 주소(`photoUrl`)만 주고 핀 좌표는 프론트가 구역별로 관리한다(`constants/lockersSectionPhotoPins.ts`, 사진 대비 0~1 비율·핀 가운데 기준). 사진을 바꾸면 좌표도 같이 고친다. 색은 칸이 묶음의 몇 번째 행인지로 정한다 — 위쪽 행일수록 진하게, 3행은 Orange/50·70·90, 5행은 50·60·70·80·90(B-2구역 실제사진 `2159:110653`). 핀 크기는 Figma 두 값(12.4·14.9px)의 중간인 14px로 고정했다. Figma의 사진 회전(1.12°)·확대 보정은 사진을 자를 때 끝내고 코드는 틀에 그대로 채운다. diff --git a/src/app/router.tsx b/src/app/router.tsx index 3c00ff7..b5d2e76 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -17,6 +17,7 @@ import FeedbacksListScreen from "@/features/feedbacks/FeedbacksListScreen"; import FeedbacksNewScreen from "@/features/feedbacks/FeedbacksNewScreen"; import HomeScreen from "@/features/home/HomeScreen"; import LockersApplyScreen from "@/features/lockers/LockersApplyScreen"; +import LockersLockerSelectScreen from "@/features/lockers/LockersLockerSelectScreen"; import LockersSectionSelectScreen from "@/features/lockers/LockersSectionSelectScreen"; import NoticesDetailScreen from "@/features/notices/NoticesDetailScreen"; import NoticesListScreen from "@/features/notices/NoticesListScreen"; @@ -133,6 +134,15 @@ const routes = [ } satisfies ScreenRouteHandle, path: "/lockers/apply/sections", }, + { + element: , + // 구역 선택 화면과 같다 — 하단 고정 선택 영역이 있고, 칸 배경이 흰 면 위에 놓인다 + handle: { + background: "normal", + hasBottomNav: false, + } satisfies ScreenRouteHandle, + path: "/lockers/apply/sections/:sectionId", + }, // 라우트가 없는 경로 — 레이아웃 안에 둬서 하단 탭이 유지되고, 탭 경로(/event 등)면 그 탭이 활성으로 보인다 { element: , path: "*" }, ], diff --git a/src/assets/icons/lockers/locker-unavailable.svg b/src/assets/icons/lockers/locker-unavailable.svg new file mode 100644 index 0000000..a87d4c0 --- /dev/null +++ b/src/assets/icons/lockers/locker-unavailable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/lockers/shelf-divider.svg b/src/assets/icons/lockers/shelf-divider.svg new file mode 100644 index 0000000..8794b45 --- /dev/null +++ b/src/assets/icons/lockers/shelf-divider.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/images/lockers/section-a-1-photo.jpg b/src/assets/images/lockers/section-a-1-photo.jpg new file mode 100644 index 0000000..c00a01c Binary files /dev/null and b/src/assets/images/lockers/section-a-1-photo.jpg differ diff --git a/src/features/lockers/LockersLockerSelectScreen.tsx b/src/features/lockers/LockersLockerSelectScreen.tsx new file mode 100644 index 0000000..761f192 --- /dev/null +++ b/src/features/lockers/LockersLockerSelectScreen.tsx @@ -0,0 +1,221 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft, IconImage, IconReset } from "@wanteddev/wds-icon"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; + +import ComingSoonScreen from "@/app/ComingSoonScreen"; +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import LockersLayoutRenderer from "@/features/lockers/components/LockersLayoutRenderer"; +import LockersLockerLegend from "@/features/lockers/components/LockersLockerLegend"; +import LockersLockerMinimap, { + type LockersLockerMinimapViewport, +} from "@/features/lockers/components/LockersLockerMinimap"; +import LockersSectionPhotoModal from "@/features/lockers/components/LockersSectionPhotoModal"; +import LockersSelectedLockerBar from "@/features/lockers/components/LockersSelectedLockerBar"; +import { + LOCKERS_SECTION_DETAILS, + type LockersSectionDetail, +} from "@/features/lockers/constants/lockersSectionDetails"; +import { LOCKERS_SECTION_PHOTO_PINS } from "@/features/lockers/constants/lockersSectionPhotoPins"; +import { useLockersPinchZoom } from "@/features/lockers/hooks/useLockersPinchZoom"; + +// 배치 영역 안쪽 여백(px-5, pb-5) — 미니맵에 보이는 영역을 배치 기준으로 계산할 때 뺀다 +const CONTENT_PADDING = 20; + +interface MinimapState { + viewport: LockersLockerMinimapViewport; + layoutWidth: number; + isScrollable: boolean; +} + +function clampRatio(value: number) { + return Math.min(Math.max(value, 0), 1); +} + +// Figma: A-1구역 (nodeId 2159:110753), A-2구역 (2159:109533), A-1구역 실제사진 (2159:110174) +function LockersLockerSelectScreen() { + const { sectionId = "" } = useParams(); + const detail = LOCKERS_SECTION_DETAILS[sectionId]; + + if (detail === undefined) { + return ; + } + + // 구역이 바뀌면 고른 칸·스크롤을 처음부터 다시 잡는다 + return ; +} + +function SectionLockerSelect({ detail }: { detail: LockersSectionDetail }) { + const navigate = useNavigate(); + const location = useLocation(); + const [selectedLockerNumber, setSelectedLockerNumber] = useState< + number | null + >(null); + const [isPhotoOpen, setIsPhotoOpen] = useState(false); + const [minimap, setMinimap] = useState(null); + const [scrollerWidth, setScrollerWidth] = useState(); + const scrollRef = useRef(null); + const contentRef = useRef(null); + + const lockers = useMemo( + () => + new Map(detail.lockers.map((locker) => [locker.lockerNumber, locker])), + [detail.lockers], + ); + const selectedLocker = + selectedLockerNumber === null + ? undefined + : lockers.get(selectedLockerNumber); + + const updateViewport = useCallback(() => { + const scroller = scrollRef.current; + const content = contentRef.current; + if (!scroller || !content) { + return; + } + // 제스처 중에는 배율이 상태보다 앞서 DOM에만 반영돼 있어서 DOM 값을 읽는다 + const zoom = Number(content.style.zoom) || 1; + const rect = content.getBoundingClientRect(); + const layoutWidth = rect.width / zoom - CONTENT_PADDING * 2; + const layoutHeight = rect.height / zoom - CONTENT_PADDING; + const visibleLeft = scroller.scrollLeft / zoom - CONTENT_PADDING; + const visibleTop = scroller.scrollTop / zoom; + const left = clampRatio(visibleLeft / layoutWidth); + const top = clampRatio(visibleTop / layoutHeight); + const right = clampRatio( + (visibleLeft + scroller.clientWidth / zoom) / layoutWidth, + ); + const bottom = clampRatio( + (visibleTop + scroller.clientHeight / zoom) / layoutHeight, + ); + + setScrollerWidth(scroller.clientWidth); + setMinimap({ + isScrollable: + scroller.scrollWidth > scroller.clientWidth + 1 || + scroller.scrollHeight > scroller.clientHeight + 1, + layoutWidth, + viewport: { height: bottom - top, left, top, width: right - left }, + }); + }, []); + + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) { + return; + } + updateViewport(); + const observer = new ResizeObserver(updateViewport); + observer.observe(el); + + return () => observer.disconnect(); + }, [updateViewport]); + + const zoom = useLockersPinchZoom(scrollRef, contentRef, updateViewport); + + useScreenHeader( + + location.key === "default" + ? navigate("/lockers/apply/sections", { replace: true }) + : navigate(-1) + } + variant="icon" + > + + + } + title={`${detail.section}구역 사물함 선택`} + trailing={ + <> + setIsPhotoOpen(true)} + variant="icon" + > + + + window.location.reload()} + variant="icon" + > + + + + } + variant="normal" + />, + ); + + const map = ( + + ); + + return ( +
+
+ {/* Figma Minimap Section */} +
+ {minimap ? ( + + {map} + + ) : ( +
+ )} + +
+ {/* touch-pan: 두 손가락 동작을 페이지 확대 대신 이 영역의 확대로 받는다. + minWidth를 %가 아니라 px로 주는 이유: zoom을 걸면 %는 배율과 상관없이 화면 폭으로 + 계산돼서, 화면보다 좁은 구역을 확대할 때 "fill" 상자가 비율대로 커지지 않는다 */} +
+
+ {map} +
+
+
+ + {}} + lockerLabel={selectedLocker?.lockerLabel ?? null} + /> + + setIsPhotoOpen(false)} + open={isPhotoOpen} + photoUrl={detail.photoUrl} + pins={LOCKERS_SECTION_PHOTO_PINS[detail.section] ?? []} + sectionName={detail.section} + /> +
+ ); +} + +export default LockersLockerSelectScreen; diff --git a/src/features/lockers/LockersSectionSelectScreen.tsx b/src/features/lockers/LockersSectionSelectScreen.tsx index 4fc4175..96c1262 100644 --- a/src/features/lockers/LockersSectionSelectScreen.tsx +++ b/src/features/lockers/LockersSectionSelectScreen.tsx @@ -13,7 +13,6 @@ import LockersFloorMap from "@/features/lockers/components/LockersFloorMap"; // Figma: 사물함 구역 선택 전 (nodeId 1737:218452), 선택완료 (1737:218489) // 새로고침(Figma Icon/Normal/Reset)은 잔여 수량 API가 없어 지금은 페이지를 다시 읽는다. -// 선택 후 이동할 화면은 아직 디자인이 없어서 지금은 아무것도 하지 않는다. function LockersSectionSelectScreen() { const [selectedSectionId, setSelectedSectionId] = useState( null, @@ -59,6 +58,9 @@ function LockersSectionSelectScreen() { {/* 높이 보정 이유는 wds-component-usage.md "Action Area 메인 버튼 높이" 참고 */} + navigate(`/lockers/apply/sections/${selectedSectionId}`) + } sx={{ paddingBlock: "16px" }} > {selectedSectionId === null diff --git a/src/features/lockers/components/LockersFloorMap.tsx b/src/features/lockers/components/LockersFloorMap.tsx index 2965360..b91011d 100644 --- a/src/features/lockers/components/LockersFloorMap.tsx +++ b/src/features/lockers/components/LockersFloorMap.tsx @@ -1,9 +1,9 @@ import { Typography } from "@wanteddev/wds"; import { IconChevronUp } from "@wanteddev/wds-icon"; -import type { ReactNode } from "react"; import restroomIcon from "@/assets/icons/lockers/restroom.svg"; import stairsIcon from "@/assets/icons/lockers/stairs.svg"; +import LockersMapArea from "@/features/lockers/components/LockersMapArea"; import LockersSectionCard from "@/features/lockers/components/LockersSectionCard"; import { LOCKERS_LEFT_SECTIONS, @@ -16,41 +16,6 @@ interface LockersFloorMapProps { onSelect: (sectionId: string) => void; } -// 호실·화장실처럼 고를 수 없는 자리. 점선 테두리에 라벨만 가운데 둔다. -function MapArea({ - children, - className, -}: { - children: ReactNode; - className: string; -}) { - return ( -
- {children} -
- ); -} - -function AreaLabel({ icon, label }: { icon?: string; label: string }) { - return ( -
- {icon !== undefined && ( - - )} - - {label} - -
- ); -} - // Figma: 사물함 구역 선택 Floor Map (nodeId 1737:218457) // // 호실·화장실·계단·복도 위치는 실제 건물 구조라 데이터로 받지 않고 여기 레이아웃이 갖는다. @@ -78,16 +43,14 @@ function LockersFloorMap({ return (
- - - + {LOCKERS_LEFT_SECTIONS.map(renderCard)} - - - - - - + +
{/* 가운데 열은 복도다 — 카드가 없고 방향 표시만 있다. @@ -121,9 +84,7 @@ function LockersFloorMap({
{LOCKERS_RIGHT_SECTIONS.map(renderCard)} - - - + {LOCKERS_RIGHT_SECTIONS_BELOW_STAIRS.map(renderCard)}
diff --git a/src/features/lockers/components/LockersLayoutRenderer.tsx b/src/features/lockers/components/LockersLayoutRenderer.tsx new file mode 100644 index 0000000..57d5f00 --- /dev/null +++ b/src/features/lockers/components/LockersLayoutRenderer.tsx @@ -0,0 +1,186 @@ +import { Typography } from "@wanteddev/wds"; +import type { CSSProperties } from "react"; + +import stairsIcon from "@/assets/icons/lockers/stairs.svg"; +import LockersLockerGrid from "@/features/lockers/components/LockersLockerGrid"; +import LockersMapArea from "@/features/lockers/components/LockersMapArea"; +import LockersMapLabel from "@/features/lockers/components/LockersMapLabel"; +import LockersShelfLabel from "@/features/lockers/components/LockersShelfLabel"; +import type { + LockersLayout, + LockersLayoutAlign, + LockersLayoutAreaBlock, + LockersLayoutBlock, + LockersLayoutSize, + LockersSectionLocker, +} from "@/features/lockers/constants/lockersSectionDetails"; + +interface LockersLayoutRendererProps { + layout: LockersLayout; + lockers: ReadonlyMap; + selectedLockerNumber: number | null; + onSelect: (lockerNumber: number) => void; +} + +type LockersLayoutBlockContext = Omit; + +// "fill"은 부모와 같은 축이면 남은 공간을 나눠 갖고(flex-1), 다른 축이면 부모에 맞춰 늘어난다 +type LockersLayoutDirection = "row" | "column"; + +const ALIGN_CLASS_NAMES: Record = { + center: "items-center", + end: "items-end", + start: "items-start", + stretch: "items-stretch", +}; + +const AREA_ICONS: Record< + NonNullable, + string +> = { + stairs: stairsIcon, +}; + +function getSizing( + block: LockersLayoutBlock, + parentDirection: LockersLayoutDirection, +) { + const isRow = parentDirection === "row"; + const mainSize: LockersLayoutSize = + (isRow ? block.width : block.height) ?? "hug"; + const crossSize: LockersLayoutSize = + (isRow ? block.height : block.width) ?? "hug"; + const mainProperty = isRow ? "width" : "height"; + const crossProperty = isRow ? "height" : "width"; + + const classNames = [mainSize === "fill" ? "flex-1" : "shrink-0"]; + const style: CSSProperties = {}; + + if (typeof mainSize === "number") { + style[mainProperty] = mainSize; + } + if (crossSize === "fill") { + classNames.push("self-stretch"); + } else if (typeof crossSize === "number") { + style[crossProperty] = crossSize; + } + + return { className: classNames.join(" "), style }; +} + +function LockersLayoutBlockView({ + block, + parentDirection, + context, +}: { + block: LockersLayoutBlock; + parentDirection: LockersLayoutDirection; + context: LockersLayoutBlockContext; +}) { + const sizing = getSizing(block, parentDirection); + + switch (block.type) { + case "row": + case "column": + return ( +
+ {block.children.map((child, index) => ( + + ))} +
+ ); + case "lockerGroup": { + const grid = ( + + ); + return block.bordered ? ( +
+ {grid} +
+ ) : ( +
+ {grid} +
+ ); + } + case "label": + return ( + + ); + case "area": + return ( + + ); + case "text": + return ( +
+ + {block.text} + +
+ ); + case "shelfLabel": + return ( + + ); + default: + // 모르는 블록은 화면 전체가 깨지지 않게 건너뛴다 + return null; + } +} + +// 최상위 블록은 부모 폭보다 좁아지지 않게 해서, 화면보다 좁은 구역의 "fill" 상자가 화면 끝까지 늘어난다 +function LockersLayoutRenderer({ + layout, + ...context +}: LockersLayoutRendererProps) { + return ( +
+ +
+ ); +} + +export default LockersLayoutRenderer; diff --git a/src/features/lockers/components/LockersLockerCell.tsx b/src/features/lockers/components/LockersLockerCell.tsx new file mode 100644 index 0000000..a338328 --- /dev/null +++ b/src/features/lockers/components/LockersLockerCell.tsx @@ -0,0 +1,82 @@ +import { Typography } from "@wanteddev/wds"; + +import lockerUnavailableIcon from "@/assets/icons/lockers/locker-unavailable.svg"; +import type { LockersSectionLocker } from "@/features/lockers/constants/lockersSectionDetails"; + +interface LockersLockerCellProps { + locker: LockersSectionLocker; + isSelected: boolean; + onSelect: (lockerNumber: number) => void; +} + +// Figma: Locker Cell (nodeId 2159:110833 외) — Stream 로컬. 28px 정사각형, Orange/95 배경에 +// 번호만 Caption 2/Regular로 가운데 둔다. +// +// 선택 불가 칸은 Figma 범례(2159:110819)와 같은 그림이다 — 회색 바탕에 대각선. 칸 번호는 Figma +// 미니맵의 선택 불가 칸(2159:109565)처럼 지운다. 범례 SVG는 `preserveAspectRatio="none"`이라 +// 28px로 늘려도 모서리가 4px로 맞는다. +// +// 선택된 칸은 Figma "사물함 선택 시"(2159:113315)대로 Primary/Normal 배경에 번호를 +// Background/Normal/Normal(흰색) SemiBold로 바꾼다. +// +// 내 사물함은 Figma에 상태가 없어서 임시로 초록 테두리를 둔다(이미 신청한 칸이라 고를 수 없다). +function LockersLockerCell({ + locker, + isSelected, + onSelect, +}: LockersLockerCellProps) { + if (locker.isMine) { + return ( +
+ + {locker.lockerNumber} + +
+ ); + } + + if (!locker.isAvailable) { + return ( + {`${locker.lockerLabel} + ); + } + + return ( + + ); +} + +export default LockersLockerCell; diff --git a/src/features/lockers/components/LockersLockerGrid.tsx b/src/features/lockers/components/LockersLockerGrid.tsx new file mode 100644 index 0000000..945379f --- /dev/null +++ b/src/features/lockers/components/LockersLockerGrid.tsx @@ -0,0 +1,55 @@ +import LockersLockerCell from "@/features/lockers/components/LockersLockerCell"; +import type { LockersSectionLocker } from "@/features/lockers/constants/lockersSectionDetails"; + +interface LockersLockerGridProps { + rows: (number | null)[][]; + lockers: ReadonlyMap; + selectedLockerNumber: number | null; + onSelect: (lockerNumber: number) => void; +} + +// Figma: Locker Grid (nodeId 2159:110832 외) +// layout에는 있는데 lockers에 없는 번호(서버 데이터가 어긋난 경우)는 빈 자리로 둔다. +function LockersLockerGrid({ + rows, + lockers, + selectedLockerNumber, + onSelect, +}: LockersLockerGridProps) { + const columnCount = Math.max(...rows.map((row) => row.length)); + + return ( +
+ {rows.flatMap((row, rowIndex) => + row.map((lockerNumber, columnIndex) => { + const locker = + lockerNumber === null ? undefined : lockers.get(lockerNumber); + + if (locker === undefined) { + return ( +
+ ); + } + + return ( + + ); + }), + )} +
+ ); +} + +export default LockersLockerGrid; diff --git a/src/features/lockers/components/LockersLockerLegend.tsx b/src/features/lockers/components/LockersLockerLegend.tsx new file mode 100644 index 0000000..9824c9b --- /dev/null +++ b/src/features/lockers/components/LockersLockerLegend.tsx @@ -0,0 +1,41 @@ +import { Typography } from "@wanteddev/wds"; +import type { ReactNode } from "react"; + +import lockerUnavailableIcon from "@/assets/icons/lockers/locker-unavailable.svg"; + +function LegendItem({ icon, label }: { icon: ReactNode; label: string }) { + return ( +
+ {icon} + + {label} + +
+ ); +} + +// Figma: Legend (nodeId 2159:110814) — Stream 로컬. 칸 색이 무엇을 뜻하는지 알려준다. +function LockersLockerLegend() { + return ( +
+ } + label="선택 가능" + /> + + } + label="선택 불가" + /> +
+ ); +} + +export default LockersLockerLegend; diff --git a/src/features/lockers/components/LockersLockerMinimap.tsx b/src/features/lockers/components/LockersLockerMinimap.tsx new file mode 100644 index 0000000..fc63bc7 --- /dev/null +++ b/src/features/lockers/components/LockersLockerMinimap.tsx @@ -0,0 +1,130 @@ +import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; + +/** 칸 배치 전체 대비 지금 보이는 영역(0~1 비율) */ +export interface LockersLockerMinimapViewport { + left: number; + top: number; + width: number; + height: number; +} + +interface LockersLockerMinimapProps { + /** 본문과 같은 칸 배치 — 축소해서 그린다 */ + children: ReactNode; + /** 본문 칸 배치의 1배 기준 폭. 같은 폭으로 그려야 "fill" 상자 비율이 본문과 같다 */ + layoutWidth: number; + viewport: LockersLockerMinimapViewport; + /** 스크롤이 없으면(전체가 보이면) 테두리를 숨긴다 */ + isScrollable: boolean; +} + +// Figma 미니맵 상자 110px, 안쪽 여백 8px. 테두리(Highlight Overlay)는 상자보다 위아래로 9px씩 크다(128px). +const BOX_HEIGHT = 110; +const BOX_PADDING = 8; +const HIGHLIGHT_OVERHANG = 9; +const AREA_HEIGHT = BOX_HEIGHT + HIGHLIGHT_OVERHANG * 2; +// 테두리가 끝에 닿았는지 볼 때 반올림 오차를 봐준다 +const EDGE_EPSILON = 0.001; + +// Figma: Minimap (nodeId 2159:110757) — Stream 로컬 +// Figma는 칸·글자를 따로 줄여 그렸지만, 본문 배치를 zoom으로 줄여 그린다(배치를 두 벌 관리하지 않고, +// 4px 글자가 브라우저 최소 글자 크기에 걸리지 않는다). 높이는 고정, 폭은 배치 비율에 맞추되 남은 폭을 넘지 않는다. +function LockersLockerMinimap({ + children, + layoutWidth, + viewport, + isScrollable, +}: LockersLockerMinimapProps) { + const areaRef = useRef(null); + const contentRef = useRef(null); + const [scale, setScale] = useState(null); + const [layoutHeight, setLayoutHeight] = useState(0); + + useLayoutEffect(() => { + const area = areaRef.current; + const content = contentRef.current; + if (!area || !content) { + return; + } + const update = () => { + const currentScale = Number(content.style.zoom) || 1; + const height = content.getBoundingClientRect().height / currentScale; + setLayoutHeight(height); + setScale( + Math.min( + (BOX_HEIGHT - BOX_PADDING * 2) / height, + (area.clientWidth - BOX_PADDING * 2) / layoutWidth, + ), + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(area); + return () => observer.disconnect(); + }, [layoutWidth]); + + const innerWidth = layoutWidth * (scale ?? 0); + const innerHeight = layoutHeight * (scale ?? 0); + const boxWidth = innerWidth + BOX_PADDING * 2; + const contentTop = HIGHLIGHT_OVERHANG + BOX_PADDING; + const right = viewport.left + viewport.width; + const bottom = viewport.top + viewport.height; + + // 끝에 닿은 쪽은 Figma처럼 상자 밖(가로는 상자 끝, 세로는 9px 바깥)까지 늘린다 + const highlightLeft = + viewport.left <= EDGE_EPSILON + ? 0 + : BOX_PADDING + viewport.left * innerWidth; + const highlightRight = + right >= 1 - EDGE_EPSILON ? boxWidth : BOX_PADDING + right * innerWidth; + const highlightTop = + viewport.top <= EDGE_EPSILON ? 0 : contentTop + viewport.top * innerHeight; + const highlightBottom = + bottom >= 1 - EDGE_EPSILON + ? AREA_HEIGHT + : contentTop + bottom * innerHeight; + + return ( +
+
+ {/* 축소본 안의 칸 버튼이 Tab·스크린리더에 두 번 잡히지 않게 막는다 */} +
+ {children} +
+
+ {isScrollable && ( +
+ )} +
+ ); +} + +export default LockersLockerMinimap; diff --git a/src/features/lockers/components/LockersMapArea.tsx b/src/features/lockers/components/LockersMapArea.tsx new file mode 100644 index 0000000..b3f9e3f --- /dev/null +++ b/src/features/lockers/components/LockersMapArea.tsx @@ -0,0 +1,40 @@ +import { Typography } from "@wanteddev/wds"; +import type { CSSProperties } from "react"; + +interface LockersMapAreaProps { + label: string; + icon?: string; + className?: string; + style?: CSSProperties; +} + +// Figma: Room Label·Zone Label·Stairs Area — 호실·계단처럼 고를 수 없는 자리 +function LockersMapArea({ + label, + icon, + className = "", + style, +}: LockersMapAreaProps) { + return ( +
+
+ {icon !== undefined && ( + + )} + + {label} + +
+
+ ); +} + +export default LockersMapArea; diff --git a/src/features/lockers/components/LockersMapLabel.tsx b/src/features/lockers/components/LockersMapLabel.tsx new file mode 100644 index 0000000..2739daf --- /dev/null +++ b/src/features/lockers/components/LockersMapLabel.tsx @@ -0,0 +1,45 @@ +import { Typography } from "@wanteddev/wds"; +import type { CSSProperties } from "react"; + +interface LockersMapLabelProps { + text: string; + orientation?: "horizontal" | "vertical"; + className?: string; + style?: CSSProperties; +} + +// Figma는 세로 라벨을 한 글자씩 줄바꿈하고 단어 사이에 빈 줄을 둔다 +function toVerticalText(text: string) { + return [...text].map((char) => (char === " " ? "" : char)).join("\n"); +} + +// Figma: Direction Label·Aisle — 창문·벽면 표시 +function LockersMapLabel({ + text, + orientation = "horizontal", + className = "", + style, +}: LockersMapLabelProps) { + const isVertical = orientation === "vertical"; + + return ( +
+ + {isVertical ? toVerticalText(text) : text} + +
+ ); +} + +export default LockersMapLabel; diff --git a/src/features/lockers/components/LockersSectionPhotoModal.tsx b/src/features/lockers/components/LockersSectionPhotoModal.tsx new file mode 100644 index 0000000..9612861 --- /dev/null +++ b/src/features/lockers/components/LockersSectionPhotoModal.tsx @@ -0,0 +1,182 @@ +import { IconButton, Typography } from "@wanteddev/wds"; +import { IconClose } from "@wanteddev/wds-icon"; +import type { KeyboardEvent } from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; + +import { useScreenSheetPortal } from "@/components/ui/useScreenSheetPortal"; +import type { + LockersLayout, + LockersLayoutBlock, +} from "@/features/lockers/constants/lockersSectionDetails"; +import type { LockersSectionPhotoPin } from "@/features/lockers/constants/lockersSectionPhotoPins"; + +interface LockersSectionPhotoModalProps { + open: boolean; + sectionName: string; + photoUrl: string; + pins: LockersSectionPhotoPin[]; + /** 핀 색을 칸의 행 위치로 정하는 데 쓴다 */ + layout: LockersLayout; + onClose: () => void; +} + +// 위쪽 행일수록 진하다 — 3행은 50·70·90, 5행은 50·60·70·80·90(Figma A-1·B-2 실제사진) +const PIN_TONE_CLASS_NAMES = [ + "bg-orange-50", + "bg-orange-60", + "bg-orange-70", + "bg-orange-80", + "bg-orange-90", +]; + +function getPinToneClassName(rowIndex: number, rowCount: number) { + if (rowCount <= 1) { + return PIN_TONE_CLASS_NAMES[0]; + } + const step = Math.round( + (rowIndex / (rowCount - 1)) * (PIN_TONE_CLASS_NAMES.length - 1), + ); + return PIN_TONE_CLASS_NAMES[step]; +} + +function collectPinToneClassNames( + block: LockersLayoutBlock, + toneClassNames: Map, +) { + if (block.type === "row" || block.type === "column") { + for (const child of block.children) { + collectPinToneClassNames(child, toneClassNames); + } + } else if (block.type === "lockerGroup") { + block.rows.forEach((row, rowIndex) => { + for (const lockerNumber of row) { + if (lockerNumber !== null) { + toneClassNames.set( + lockerNumber, + getPinToneClassName(rowIndex, block.rows.length), + ); + } + } + }); + } + return toneClassNames; +} + +// Figma: A-1구역 실제사진 Modal (nodeId 2159:110299) — Stream 로컬. 닫기 아이콘 + 구역 사진에 +// 칸 번호 핀을 얹고 아래에 안내 문구를 단 카드다. WDS `Modal`은 너비·모서리가 달라서 ConfirmModal과 같은 방식으로 +// 화면 포털에 직접 그린다. 닫기만 WDS `IconButton` + `IconClose`(Figma "Name=close, Thick=False")다. +// +// Figma 카드는 top 229px에 있지만 화면 높이가 기기마다 달라서 세로 가운데에 둔다. +function LockersSectionPhotoModal({ + open, + sectionName, + photoUrl, + pins, + layout, + onClose, +}: LockersSectionPhotoModalProps) { + const portalEl = useScreenSheetPortal(); + const dialogRef = useRef(null); + const lastFocusedRef = useRef(null); + const pinToneClassNames = useMemo( + () => collectPinToneClassNames(layout.root, new Map()), + [layout], + ); + + // 열리면 포커스를 모달로 옮기고, 닫히면 사진 버튼으로 되돌린다 + useEffect(() => { + if (!open) { + return; + } + lastFocusedRef.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + dialogRef.current?.focus(); + + return () => lastFocusedRef.current?.focus(); + }, [open]); + + // 포커스할 곳이 닫기 버튼뿐이라 Tab 가두기는 필요 없다 — Esc로 닫는 것만 받는다 + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + if (!portalEl) { + return null; + } + + return createPortal( +
+ {/* 딤은 Figma "딤드"(nodeId 2159:110298) 그대로 검정 60% */} +
, + portalEl, + ); +} + +export default LockersSectionPhotoModal; diff --git a/src/features/lockers/components/LockersSelectedLockerBar.tsx b/src/features/lockers/components/LockersSelectedLockerBar.tsx new file mode 100644 index 0000000..6e42e53 --- /dev/null +++ b/src/features/lockers/components/LockersSelectedLockerBar.tsx @@ -0,0 +1,67 @@ +import { ActionArea, ActionAreaButton, Typography } from "@wanteddev/wds"; + +interface LockersSelectedLockerBarProps { + /** 고르기 전이면 null */ + lockerLabel: string | null; + onSubmit: () => void; +} + +// Figma: Locker Selection Section (nodeId 1658:179182) — Stream 로컬. 위가 둥근 흰 카드에 +// "선택한 사물함" 줄(Figma Locker Selector, 1643:178050)과 WDS Action Area를 담는다. +// 그림자는 Figma Shadow/Spread/Small 값 그대로다. +// +// 선택 전(State=Empty)은 안내 문구 + 잠긴 버튼, 선택 후(Figma "사물함 선택 시" 2159:113198)는 +// 사물함 이름을 Primary/Normal SemiBold로 보여주고 버튼을 연다. +function LockersSelectedLockerBar({ + lockerLabel, + onSubmit, +}: LockersSelectedLockerBarProps) { + const hasSelection = lockerLabel !== null; + + return ( +
+
+ + 선택한 사물함 + + {hasSelection ? ( + + {lockerLabel} + + ) : ( + + 선택한 사물함이 없어요. + + )} +
+ + {/* 높이 보정 이유는 wds-component-usage.md "Action Area 메인 버튼 높이" 참고 */} + + {hasSelection ? "사물함 신청하기" : "사물함을 선택해 주세요"} + + +
+
+ ); +} + +export default LockersSelectedLockerBar; diff --git a/src/features/lockers/components/LockersShelfLabel.tsx b/src/features/lockers/components/LockersShelfLabel.tsx new file mode 100644 index 0000000..1ff65f9 --- /dev/null +++ b/src/features/lockers/components/LockersShelfLabel.tsx @@ -0,0 +1,51 @@ +import { Typography } from "@wanteddev/wds"; + +import shelfDividerIcon from "@/assets/icons/lockers/shelf-divider.svg"; + +interface LockersShelfLabelProps { + /** 칸 묶음 높이(px). 없으면 같은 줄 높이에 맞춰 늘어난다 */ + height?: number; +} + +function ShelfText({ children }: { children: string }) { + return ( + + {children} + + ); +} + +// Figma: Shelf Label (nodeId 1628:174373) — Stream 로컬. 칸 묶음 왼쪽에서 위아래 방향을 알려준다. +// 가운데 점선은 Figma가 가로 Divider를 90° 돌려 그린 것이라 같은 방식(컨테이너 단위로 길이를 +// 맞춘 뒤 회전)으로 옮긴다. SVG 원본 크기(120×1.2)는 건드리지 않는다. +function LockersShelfLabel({ height }: LockersShelfLabelProps) { + return ( +
+ 위쪽 칸 +
+ +
+ 아래쪽 칸 +
+ ); +} + +export default LockersShelfLabel; diff --git a/src/features/lockers/constants/lockersSectionDetails.ts b/src/features/lockers/constants/lockersSectionDetails.ts new file mode 100644 index 0000000..f1ee28b --- /dev/null +++ b/src/features/lockers/constants/lockersSectionDetails.ts @@ -0,0 +1,233 @@ +import sectionA1Photo from "@/assets/images/lockers/section-a-1-photo.jpg"; + +// 구역 상세 응답(GET /v1/app/lockers/sections/{sectionId}). layout은 구조만 갖고 칸 상태는 lockers에서 +// 온다 — 둘은 lockerNumber로 잇는다. + +// "hug"는 내용 크기, "fill"은 부모의 남은 공간, 숫자는 px +export type LockersLayoutSize = "hug" | "fill" | number; + +export type LockersLayoutAlign = "start" | "center" | "end" | "stretch"; + +interface LockersLayoutBlockBase { + width?: LockersLayoutSize; + height?: LockersLayoutSize; +} + +export interface LockersLayoutContainerBlock extends LockersLayoutBlockBase { + type: "row" | "column"; + children: LockersLayoutBlock[]; + gap?: number; + align?: LockersLayoutAlign; +} + +// rows[0]이 맨 위 행(위쪽 칸), 숫자는 lockerNumber, null은 빈 자리 +export interface LockersLayoutLockerGroupBlock extends LockersLayoutBlockBase { + type: "lockerGroup"; + rows: (number | null)[][]; + bordered?: boolean; +} + +export interface LockersLayoutLabelBlock extends LockersLayoutBlockBase { + type: "label"; + text: string; + orientation?: "horizontal" | "vertical"; +} + +export interface LockersLayoutAreaBlock extends LockersLayoutBlockBase { + type: "area"; + text: string; + icon?: "stairs"; +} + +export interface LockersLayoutTextBlock extends LockersLayoutBlockBase { + type: "text"; + text: string; +} + +export interface LockersLayoutShelfLabelBlock extends LockersLayoutBlockBase { + type: "shelfLabel"; +} + +export type LockersLayoutBlock = + | LockersLayoutContainerBlock + | LockersLayoutLockerGroupBlock + | LockersLayoutLabelBlock + | LockersLayoutAreaBlock + | LockersLayoutTextBlock + | LockersLayoutShelfLabelBlock; + +export interface LockersLayout { + version: 1; + root: LockersLayoutBlock; +} + +export interface LockersSectionLocker { + lockerId: number; + lockerNumber: number; + // 관리자가 입력하는 값이라 번호에서 만들지 않는다 + lockerLabel: string; + isAvailable: boolean; + isMine: boolean; +} + +export interface LockersSectionDetail { + sectionId: number; + section: string; + layout: LockersLayout; + photoUrl: string; + lockers: LockersSectionLocker[]; +} + +// Figma 목업이 전부 선택 가능이라 그대로 두고, 이름은 Figma 표기("A-25")대로 만든다 +function createMockLockers( + building: string, + lockerNumbers: number[], +): LockersSectionLocker[] { + return lockerNumbers.map((lockerNumber) => ({ + isAvailable: true, + isMine: false, + lockerId: lockerNumber, + lockerLabel: `${building}-${lockerNumber}`, + lockerNumber, + })); +} + +function range(from: number, to: number) { + return Array.from({ length: to - from + 1 }, (_, index) => from + index); +} + +// Figma: A-1구역 (nodeId 2159:110753) +const A1_SECTION: LockersSectionDetail = { + layout: { + root: { + align: "end", + children: [ + { + align: "end", + children: [ + { height: 122, type: "shelfLabel" }, + { + align: "center", + children: [ + { text: "창문", type: "label", width: 224 }, + { + children: [ + { + height: "fill", + orientation: "vertical", + text: "왼쪽 벽면", + type: "label", + }, + { + bordered: true, + rows: [ + [10, 11, 12], + [13, 14, 15], + [16, 17, 18], + ], + type: "lockerGroup", + }, + { + bordered: true, + rows: [ + [25, 26, 27, 28, 29, 30], + [22, 23, 24, 31, 32, 33], + [19, 20, 21, 34, 35, 36], + ], + type: "lockerGroup", + }, + { + bordered: true, + rows: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + type: "lockerGroup", + }, + { + height: "fill", + orientation: "vertical", + text: "오른쪽 벽면", + type: "label", + }, + ], + gap: 12, + type: "row", + }, + ], + gap: 8, + type: "column", + }, + ], + gap: 12, + type: "row", + }, + { text: "복도", type: "text", width: 560 }, + ], + gap: 32, + type: "column", + }, + version: 1, + }, + lockers: createMockLockers("A", range(1, 36)), + photoUrl: sectionA1Photo, + section: "A-1", + sectionId: 1, +}; + +// Figma: A-2구역 (nodeId 2159:109533) +// A-2 사진이 아직 없어서 A-1 사진을 임시로 쓴다. +const A2_SECTION: LockersSectionDetail = { + layout: { + root: { + children: [ + { height: 96, type: "shelfLabel" }, + { + align: "center", + children: [ + { + children: [ + { + rows: [ + [82, 83, 84], + [85, 86, 87], + [88, 89, 90], + ], + type: "lockerGroup", + }, + { + height: "fill", + text: "B-1구역", + type: "area", + width: "fill", + }, + ], + gap: 12, + type: "row", + width: "fill", + }, + { text: "복도", type: "text" }, + { text: "231호실", type: "area", width: "fill" }, + ], + gap: 32, + type: "column", + width: "fill", + }, + ], + gap: 12, + type: "row", + }, + version: 1, + }, + lockers: createMockLockers("A", range(82, 90)), + photoUrl: sectionA1Photo, + section: "A-2", + sectionId: 2, +}; + +// 주소가 구역 라벨(/lockers/apply/sections/A-1)이라 라벨로 찾는다 +export const LOCKERS_SECTION_DETAILS: Record = { + "A-1": A1_SECTION, + "A-2": A2_SECTION, +}; diff --git a/src/features/lockers/constants/lockersSectionPhotoPins.ts b/src/features/lockers/constants/lockersSectionPhotoPins.ts new file mode 100644 index 0000000..d7d8b60 --- /dev/null +++ b/src/features/lockers/constants/lockersSectionPhotoPins.ts @@ -0,0 +1,56 @@ +// 사진 위 칸 번호 핀. 서버는 사진 주소만 주고, 핀 좌표는 구역별로 여기서 관리한다 — 사진을 바꾸면 같이 고친다. +// x·y는 핀 가운데 위치를 사진 폭·높이 대비 0~1 비율로 적는다. +export interface LockersSectionPhotoPin { + lockerNumber: number; + x: number; + y: number; +} + +// Figma: A-1구역 실제사진 (nodeId 2159:110299) +// Figma는 사진을 1.12° 돌려 붙였는데 회전은 무시하고 비율로 옮겼다(가장자리에서 2~3px 차이). +const A1_PINS: LockersSectionPhotoPin[] = [ + { lockerNumber: 1, x: 0.758, y: 0.392 }, + { lockerNumber: 2, x: 0.836, y: 0.403 }, + { lockerNumber: 3, x: 0.936, y: 0.418 }, + { lockerNumber: 4, x: 0.758, y: 0.54 }, + { lockerNumber: 5, x: 0.836, y: 0.594 }, + { lockerNumber: 6, x: 0.936, y: 0.667 }, + { lockerNumber: 7, x: 0.758, y: 0.667 }, + { lockerNumber: 8, x: 0.836, y: 0.758 }, + { lockerNumber: 9, x: 0.936, y: 0.852 }, + { lockerNumber: 10, x: 0.081, y: 0.437 }, + { lockerNumber: 11, x: 0.175, y: 0.413 }, + { lockerNumber: 12, x: 0.243, y: 0.396 }, + { lockerNumber: 13, x: 0.081, y: 0.635 }, + { lockerNumber: 14, x: 0.175, y: 0.583 }, + { lockerNumber: 15, x: 0.243, y: 0.54 }, + { lockerNumber: 16, x: 0.081, y: 0.848 }, + { lockerNumber: 17, x: 0.175, y: 0.751 }, + { lockerNumber: 18, x: 0.243, y: 0.678 }, + { lockerNumber: 19, x: 0.345, y: 0.622 }, + { lockerNumber: 20, x: 0.406, y: 0.622 }, + { lockerNumber: 21, x: 0.466, y: 0.622 }, + { lockerNumber: 22, x: 0.345, y: 0.521 }, + { lockerNumber: 23, x: 0.406, y: 0.521 }, + { lockerNumber: 24, x: 0.466, y: 0.521 }, + { lockerNumber: 25, x: 0.345, y: 0.419 }, + { lockerNumber: 26, x: 0.406, y: 0.419 }, + { lockerNumber: 27, x: 0.466, y: 0.419 }, + { lockerNumber: 28, x: 0.527, y: 0.419 }, + { lockerNumber: 29, x: 0.588, y: 0.419 }, + { lockerNumber: 30, x: 0.648, y: 0.419 }, + { lockerNumber: 31, x: 0.527, y: 0.521 }, + { lockerNumber: 32, x: 0.588, y: 0.521 }, + { lockerNumber: 33, x: 0.648, y: 0.521 }, + { lockerNumber: 34, x: 0.527, y: 0.622 }, + { lockerNumber: 35, x: 0.588, y: 0.622 }, + { lockerNumber: 36, x: 0.648, y: 0.622 }, +]; + +// 구역 라벨로 찾는다. A-2는 사진이 아직 없어서(A-1 사진 임시 사용) 핀이 없다. +export const LOCKERS_SECTION_PHOTO_PINS: Record< + string, + LockersSectionPhotoPin[] +> = { + "A-1": A1_PINS, +}; diff --git a/src/features/lockers/hooks/useLockersPinchZoom.ts b/src/features/lockers/hooks/useLockersPinchZoom.ts new file mode 100644 index 0000000..88d6c43 --- /dev/null +++ b/src/features/lockers/hooks/useLockersPinchZoom.ts @@ -0,0 +1,203 @@ +import { + type RefObject, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +const MAX_ZOOM = 3; +// 핀치를 끝낸 손가락이 칸을 누른 것으로 처리되지 않게 막는 시간 +const CLICK_SUPPRESS_MS = 300; +const WHEEL_COMMIT_DELAY_MS = 150; + +interface Point { + x: number; + y: number; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getTouchDistance(touches: TouchList) { + return Math.hypot( + touches[0].clientX - touches[1].clientX, + touches[0].clientY - touches[1].clientY, + ); +} + +function getTouchMidpoint(touches: TouchList): Point { + return { + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2, + }; +} + +// 칸 배치를 핀치·Ctrl+휠로 확대·축소한다(최소: 배치 전체가 화면 폭에 들어오는 배율, 최대 3배). +// transform: scale은 스크롤 영역 크기에 반영되지 않아서 CSS zoom을 쓴다. 제스처 중에는 리렌더 없이 +// DOM에 바로 적용하고 끝나면 상태로 확정한다. +export function useLockersPinchZoom( + scrollRef: RefObject, + contentRef: RefObject, + onZoomChange?: () => void, +) { + const [zoom, setZoom] = useState(1); + const zoomRef = useRef(1); + const minZoomRef = useRef(1); + const onZoomChangeRef = useRef(onZoomChange); + + useLayoutEffect(() => { + onZoomChangeRef.current = onZoomChange; + }, [onZoomChange]); + + useEffect(() => { + const scroller = scrollRef.current; + const content = contentRef.current; + if (!scroller || !content) { + return; + } + + const toContentPoint = (anchor: Point): Point => ({ + x: (scroller.scrollLeft + anchor.x) / zoomRef.current, + y: (scroller.scrollTop + anchor.y) / zoomRef.current, + }); + const toAnchor = (clientPoint: Point): Point => { + const rect = scroller.getBoundingClientRect(); + return { x: clientPoint.x - rect.left, y: clientPoint.y - rect.top }; + }; + + const zoomAround = (nextZoom: number, anchor: Point, point: Point) => { + let clamped = clamp(nextZoom, minZoomRef.current, MAX_ZOOM); + content.style.zoom = String(clamped); + // zoom은 길이마다 반올림돼서 최소 배율에서도 1~2px 넘칠 수 있다 + if ( + clamped === minZoomRef.current && + scroller.scrollWidth > scroller.clientWidth + ) { + minZoomRef.current *= scroller.clientWidth / scroller.scrollWidth; + clamped = minZoomRef.current; + content.style.zoom = String(clamped); + } + zoomRef.current = clamped; + scroller.scrollLeft = point.x * clamped - anchor.x; + scroller.scrollTop = point.y * clamped - anchor.y; + onZoomChangeRef.current?.(); + }; + + const updateMinZoom = () => { + const naturalWidth = + content.getBoundingClientRect().width / zoomRef.current; + minZoomRef.current = Math.min(1, scroller.clientWidth / naturalWidth); + if (zoomRef.current < minZoomRef.current) { + zoomAround( + minZoomRef.current, + { x: 0, y: 0 }, + toContentPoint({ x: 0, y: 0 }), + ); + setZoom(zoomRef.current); + } + }; + + let pinch: { distance: number; zoom: number; point: Point } | null = null; + let suppressClickUntil = 0; + let wheelCommitTimer: number | undefined; + + const handleTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 2) { + return; + } + pinch = { + distance: getTouchDistance(event.touches), + point: toContentPoint(toAnchor(getTouchMidpoint(event.touches))), + zoom: zoomRef.current, + }; + suppressClickUntil = Number.POSITIVE_INFINITY; + }; + + const handleTouchMove = (event: TouchEvent) => { + if (!pinch || event.touches.length !== 2) { + return; + } + event.preventDefault(); + zoomAround( + (pinch.zoom * getTouchDistance(event.touches)) / pinch.distance, + toAnchor(getTouchMidpoint(event.touches)), + pinch.point, + ); + }; + + const handleTouchEnd = (event: TouchEvent) => { + if (!pinch || event.touches.length >= 2) { + return; + } + pinch = null; + suppressClickUntil = performance.now() + CLICK_SUPPRESS_MS; + setZoom(zoomRef.current); + }; + + const handleClickCapture = (event: MouseEvent) => { + if (performance.now() < suppressClickUntil) { + event.stopPropagation(); + event.preventDefault(); + } + }; + + const handleWheel = (event: WheelEvent) => { + // 트랙패드 핀치는 Ctrl+휠로 들어온다 + if (!event.ctrlKey) { + return; + } + event.preventDefault(); + const anchor = toAnchor({ x: event.clientX, y: event.clientY }); + zoomAround( + zoomRef.current * Math.exp(-event.deltaY * 0.01), + anchor, + toContentPoint(anchor), + ); + window.clearTimeout(wheelCommitTimer); + wheelCommitTimer = window.setTimeout( + () => setZoom(zoomRef.current), + WHEEL_COMMIT_DELAY_MS, + ); + }; + + updateMinZoom(); + const resizeObserver = new ResizeObserver(updateMinZoom); + resizeObserver.observe(scroller); + + scroller.addEventListener("touchstart", handleTouchStart, { + passive: true, + }); + // 페이지 확대를 preventDefault로 막아야 해서 passive가 아니다 + scroller.addEventListener("touchmove", handleTouchMove, { passive: false }); + scroller.addEventListener("touchend", handleTouchEnd); + scroller.addEventListener("touchcancel", handleTouchEnd); + scroller.addEventListener("click", handleClickCapture, { capture: true }); + scroller.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + resizeObserver.disconnect(); + window.clearTimeout(wheelCommitTimer); + scroller.removeEventListener("touchstart", handleTouchStart); + scroller.removeEventListener("touchmove", handleTouchMove); + scroller.removeEventListener("touchend", handleTouchEnd); + scroller.removeEventListener("touchcancel", handleTouchEnd); + scroller.removeEventListener("click", handleClickCapture, { + capture: true, + }); + scroller.removeEventListener("wheel", handleWheel); + }; + }, [scrollRef, contentRef]); + + // 처음에는 가운데부터 보여준다 + useLayoutEffect(() => { + const scroller = scrollRef.current; + if (!scroller) { + return; + } + scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2; + }, [scrollRef]); + + return zoom; +} diff --git a/src/index.css b/src/index.css index 6ef9815..d2a2164 100644 --- a/src/index.css +++ b/src/index.css @@ -47,6 +47,17 @@ Background/Normal/Alternative(#f7f7f8)보다 한 단계 진해서 잠긴 걸 드러낸다 */ --color-cool-neutral-97: var(--atomic-coolNeutral-97); + /* 사물함 칸 선택 — 선택 가능한 칸 배경(Figma "Orange/95")과 칸 묶음 테두리(Figma "Line/Solid/Alternative") */ + --color-orange-95: var(--atomic-orange-95); + --color-line-solid-alternative: var(--semantic-line-solid-alternative); + /* 구역 실제 사진 위 칸 번호 핀 — 위쪽 칸일수록 진하다. 3행 묶음은 Figma "Orange/50" → "70" → "90", + 5행 묶음(B-2구역 실제사진 2159:110653)은 50 → 60 → 70 → 80 → 90 */ + --color-orange-50: var(--atomic-orange-50); + --color-orange-60: var(--atomic-orange-60); + --color-orange-70: var(--atomic-orange-70); + --color-orange-80: var(--atomic-orange-80); + --color-orange-90: var(--atomic-orange-90); + --color-status-positive: var(--semantic-status-positive); --color-inverse-background: var(--semantic-inverse-background);