diff --git a/dockerfile.dev b/dockerfile.dev index de478ac0..7228ea0f 100644 --- a/dockerfile.dev +++ b/dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:16 +FROM node:18 WORKDIR /usr/src/app COPY package.json ./ RUN yarn diff --git a/src/@types/building-info.ts b/src/@types/building-info.ts new file mode 100644 index 00000000..d462e59b --- /dev/null +++ b/src/@types/building-info.ts @@ -0,0 +1,16 @@ +export interface Room { + roomNumber: string; + roomName: string; +} + +export type Floor = 'basement' | 'ground' | 'rooftop'; + +export type TotalFloorInfo = { + [key in Floor]: { + [key: string]: Room[]; + }; +}; + +export type FloorInfo = { + [key: string]: Room[]; +}; diff --git a/src/@types/map.ts b/src/@types/map.ts index 2e9e3962..d0a21cbd 100644 --- a/src/@types/map.ts +++ b/src/@types/map.ts @@ -1,6 +1,7 @@ export type BuildingType = 'A' | 'B' | 'C' | 'D' | 'E'; export interface PKNUBuilding { + readonly buildingCode: string; readonly buildingNumber: string; readonly buildingName: string; readonly latlng: [number, number]; diff --git a/src/@types/styles/icon.ts b/src/@types/styles/icon.ts index 364cad69..9daadbcf 100644 --- a/src/@types/styles/icon.ts +++ b/src/@types/styles/icon.ts @@ -24,7 +24,8 @@ export type IconKind = | 'light' | 'checkedRadio' | 'uncheckedRadio' - | 'location' | 'warning' | 'account' - | 'language'; + | 'language' + | 'myLocation' + | 'location'; diff --git a/src/@types/styles/size.ts b/src/@types/styles/size.ts index 75159751..10b73b69 100644 --- a/src/@types/styles/size.ts +++ b/src/@types/styles/size.ts @@ -1,6 +1,6 @@ import { CSSProperties } from 'react'; -export type SizeOption = 'large' | 'medium' | 'small' | 'tiny'; +export type SizeOption = 'large' | 'medium' | 'small' | 'tiny' | 'building'; export interface Size { height: CSSProperties['height']; diff --git a/src/apis/building-info/fetch-building-info.ts b/src/apis/building-info/fetch-building-info.ts new file mode 100644 index 00000000..9923963e --- /dev/null +++ b/src/apis/building-info/fetch-building-info.ts @@ -0,0 +1,16 @@ +import http from '@apis/http'; +import { SERVER_URL } from '@config/index'; + +const fetchBuildingInfo = async (buildingCode: string) => { + try { + const buildingInfo = await http.get( + `${SERVER_URL}/api/buildingInfo?code=${buildingCode}`, + ); + + return buildingInfo; + } catch (error) { + return error; + } +}; + +export default fetchBuildingInfo; diff --git a/src/components/Common/Icon/index.tsx b/src/components/Common/Icon/index.tsx index 06c0a05e..a2e26e92 100644 --- a/src/components/Common/Icon/index.tsx +++ b/src/components/Common/Icon/index.tsx @@ -30,6 +30,7 @@ import { MdOutlineKeyboardArrowDown, MdAssignmentInd, MdLanguage, + MdOutlineLocationOn, } from 'react-icons/md'; const ICON: { [key in IconKind]: IconType } = { @@ -58,10 +59,11 @@ const ICON: { [key in IconKind]: IconType } = { light: MdOutlineLightbulb, uncheckedRadio: MdRadioButtonUnchecked, checkedRadio: MdRadioButtonChecked, - location: MdOutlineMyLocation, warning: MdOutlineError, account: MdAssignmentInd, language: MdLanguage, + myLocation: MdOutlineMyLocation, + location: MdOutlineLocationOn, }; interface IconProps { diff --git a/src/components/Common/Image/index.tsx b/src/components/Common/Image/index.tsx index c4572406..b9a24ee7 100644 --- a/src/components/Common/Image/index.tsx +++ b/src/components/Common/Image/index.tsx @@ -8,6 +8,7 @@ const imageSize: ImageSize = { medium: setSize(150), small: setSize(100), tiny: setSize(80), + building: setSize(100, 180), }; const Image = ({ diff --git a/src/components/Common/ToggleInfo/index.tsx b/src/components/Common/ToggleInfo/index.tsx new file mode 100644 index 00000000..8c50d351 --- /dev/null +++ b/src/components/Common/ToggleInfo/index.tsx @@ -0,0 +1,53 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { THEME } from '@styles/ThemeProvider/theme'; +import React, { useState } from 'react'; + +import Icon from '../Icon'; + +interface ToggleInfoProps { + infoTitle: () => JSX.Element; + infoDesc: () => JSX.Element; +} + +const ToggleInfo = ({ infoTitle, infoDesc }: ToggleInfoProps) => { + const [showInfo, setShowInfo] = useState(false); + const toggleInfo = () => setShowInfo((prevState) => !prevState); + + return ( + <> + + {infoTitle()} + + + + + {showInfo && infoDesc()} + + ); +}; + +export default ToggleInfo; + +const ToggleContainer = styled.section<{ showInfo: boolean }>` + position: relative; + padding: 10px 0px 10px 0px; + display: flex; + align-items: center; + + ${({ showInfo }) => css` + & > span { + color: ${showInfo && THEME.PRIMARY}; + } + & > div > svg { + transform: ${showInfo ? 'rotate(-180deg)' : 'rotate(0deg)'}; + transition: all ease 0.3s; + } + `} +`; + +const IconContainer = styled.div` + position: absolute; + right: 0; + display: flex; +`; diff --git a/src/components/FooterTab/index.tsx b/src/components/FooterTab/index.tsx index 545fcd6d..a8352f9f 100644 --- a/src/components/FooterTab/index.tsx +++ b/src/components/FooterTab/index.tsx @@ -32,19 +32,17 @@ const FooterTab = () => { }; const Footer = styled.div` + position: fixed; + bottom: 0; display: flex; justify-content: space-around; align-items: center; - max-width: 480px; width: 100%; height: 60px; padding: 15px 0px 15px 0px; background-color: ${THEME.TEXT.WHITE}; - position: fixed; - bottom: 0; - z-index: 2; - + z-index: 999; box-shadow: 0px -2px 6px rgba(99, 99, 99, 0.2); `; diff --git a/src/components/Providers/MapProvider/index.tsx b/src/components/Providers/MapProvider/index.tsx index 2d0c7704..df17c8ed 100644 --- a/src/components/Providers/MapProvider/index.tsx +++ b/src/components/Providers/MapProvider/index.tsx @@ -7,6 +7,7 @@ import { PknuMap, RefreshButtons, } from '@pages/Map/components'; +import BuildingInfoToggle from '@pages/Map/components/BuildingInfo/BuildingInfoToggle'; import { Location } from '@type/map'; import React, { useState } from 'react'; @@ -37,8 +38,12 @@ Map.PknuMap = PknuMap; Map.MapHeader = MapHeader; Map.FilterButtons = FilterButtons; Map.RefreshButtons = RefreshButtons; +Map.BuildingInfoToggle = BuildingInfoToggle; const MapContainer = styled.div` + overflow: hidden; + max-width: 480px; + min-height: 100vh; height: calc(100vh - 8vh); display: flex; flex-direction: column; diff --git a/src/components/Providers/OverlayProvider/index.tsx b/src/components/Providers/OverlayProvider/index.tsx index 8a1a2578..356f192e 100644 --- a/src/components/Providers/OverlayProvider/index.tsx +++ b/src/components/Providers/OverlayProvider/index.tsx @@ -11,25 +11,7 @@ interface OverlayProviderProps { } const OverlayProvider = ({ children }: OverlayProviderProps) => { - const userLocation = useUserLocation(); - const { openModal } = useModals(); - - const handleOpenModal = ( - title: string, - btn1Text: string, - onClick?: () => void, - btn2Text?: string, - ) => { - openModal( - - - - {btn2Text && } - , - ); - }; - - const customOverlay = new CustomOverlay(handleOpenModal, userLocation); + const customOverlay = new CustomOverlay(); return ( diff --git a/src/components/Providers/OverlayProvider/overlay.ts b/src/components/Providers/OverlayProvider/overlay.ts index c19708f1..6ee04df7 100644 --- a/src/components/Providers/OverlayProvider/overlay.ts +++ b/src/components/Providers/OverlayProvider/overlay.ts @@ -1,9 +1,6 @@ -import { MODAL_BUTTON_MESSAGE, MODAL_MESSAGE } from '@constants/modal-messages'; import { PKNU_BUILDINGS } from '@constants/pknu-map'; import { THEME } from '@styles/ThemeProvider/theme'; -import { BuildingType, Location, PKNUBuilding } from '@type/map'; -import { hasLocationPermission } from '@utils/map'; -import openLink from '@utils/router/openLink'; +import { BuildingType, PKNUBuilding } from '@type/map'; import { CSSProperties } from 'react'; interface ICustomOverlay { @@ -15,21 +12,10 @@ interface ICustomOverlay { ): void; } -type OpenModal = ( - title: string, - btn1Text: string, - onClick?: () => void, - btn2Text?: string, -) => void; - class CustomOverlay implements ICustomOverlay { private overlays: Record; - private openModal: OpenModal; - private userLocation: Location | null; - constructor(openModal: OpenModal, userLocation: Location | null) { - this.openModal = openModal; - this.userLocation = userLocation; + constructor() { this.overlays = { A: [], B: [], @@ -41,6 +27,7 @@ class CustomOverlay implements ICustomOverlay { private isOverlayInMap(buildingType: BuildingType, building: PKNUBuilding) { const type = buildingType as keyof typeof this.overlays; + if (this.overlays[type].length === 0) return false; this.overlays[type].forEach((overlay) => { if (overlay.cc.innerText === building.buildingName) return true; @@ -53,43 +40,12 @@ class CustomOverlay implements ICustomOverlay { return this.overlays[type].length >= PKNU_BUILDINGS[type].buildings.length; } - private openNoLocationModal() { - this.openModal( - MODAL_MESSAGE.ALERT.NO_LOCATION_PERMISSON, - MODAL_BUTTON_MESSAGE.CLOSE, - ); - } - - private openConfirmRoutingModal(buildingInfo: PKNUBuilding) { - const { buildingNumber, buildingName, latlng } = buildingInfo; - const [lat, lng] = latlng; - - const kakaoMapAppURL = `kakaomap://route?sp=${this.userLocation?.LAT},${this.userLocation?.LNG}&ep=${lat},${lng}`; - const kakaoMapWebURL = `https://map.kakao.com/link/from/현위치,${this.userLocation?.LAT},${this.userLocation?.LNG}/to/${buildingName},${lat},${lng}`; - const isKakaoMapInstalled = /KAKAOMAP/i.test(navigator.userAgent); - const openUrl = isKakaoMapInstalled ? kakaoMapAppURL : kakaoMapWebURL; - - this.openModal( - `목적지(${buildingNumber})로 길찾기를 시작할까요?`, - MODAL_BUTTON_MESSAGE.NO, - () => openLink(openUrl), - MODAL_BUTTON_MESSAGE.YES, - ); - } - - private handleRoutingModal(building: PKNUBuilding) { - if (!this.userLocation) return; - - hasLocationPermission(this.userLocation) - ? this.openConfirmRoutingModal(building) - : this.openNoLocationModal(); - } - private createOverlayContent( activeColor: CSSProperties['color'], building: PKNUBuilding, ) { const content = document.createElement('span') as HTMLSpanElement; + Object.assign(content.style, { backgroundColor: `${activeColor}`, color: THEME.TEXT.WHITE, @@ -98,9 +54,9 @@ class CustomOverlay implements ICustomOverlay { fontSize: '10px', fontWeight: 'bold', }); + const buildingNumberText = document.createTextNode(building.buildingNumber); content.appendChild(buildingNumberText); - content.onclick = () => this.handleRoutingModal(building); return content; } @@ -110,6 +66,7 @@ class CustomOverlay implements ICustomOverlay { PKNU_BUILDINGS[type].activeColor, building, ); + const overlay = new window.kakao.maps.CustomOverlay({ position: new window.kakao.maps.LatLng( building.latlng[0], @@ -138,6 +95,7 @@ class CustomOverlay implements ICustomOverlay { addOverlay(buildingType: BuildingType, building: PKNUBuilding, map: any) { const type = buildingType as keyof typeof this.overlays; + if (!this.isOverlayInMap(buildingType, building)) { const overlay = this.createOverlay(buildingType, building); overlay.setMap(map); diff --git a/src/constants/pknu-map.ts b/src/constants/pknu-map.ts index f217fb99..7690a38d 100644 --- a/src/constants/pknu-map.ts +++ b/src/constants/pknu-map.ts @@ -13,41 +13,49 @@ export const PKNU_BUILDINGS: PKNUBuildings = { activeColor: '#FF569E', buildings: [ { + buildingCode: 'B0000001', buildingNumber: 'A11', buildingName: '대학본부', latlng: [35.13397705691482, 129.10312908129794], }, { + buildingCode: 'B0000002', buildingNumber: 'A12', buildingName: '웅비관', latlng: [35.13444674928486, 129.1031985811075], }, { + buildingCode: 'B0000003', buildingNumber: 'A13', buildingName: '누리관', latlng: [35.134732247837064, 129.10310188800958], }, { + buildingCode: 'B0000013', buildingNumber: 'A15', buildingName: '향파관', latlng: [35.135256431017474, 129.10288500581757], }, { + buildingCode: 'B0000063', buildingNumber: 'A21', buildingName: '미래관', latlng: [35.13393257601037, 129.10218728388455], }, { + buildingCode: 'B0000011', buildingNumber: 'A22', buildingName: '디자인관', latlng: [35.134206208658554, 129.10147854379952], }, { + buildingCode: 'B0000012', buildingNumber: 'A23', buildingName: '나래관', latlng: [35.134827192477715, 129.10178520781048], }, { + buildingCode: 'B0000047', buildingNumber: 'A26', buildingName: '부산창업카페 2호점', latlng: [35.135198384658516, 129.10116672530137], @@ -58,36 +66,43 @@ export const PKNU_BUILDINGS: PKNUBuildings = { activeColor: '#FF9B29', buildings: [ { + buildingCode: 'B0000016', buildingNumber: 'B11', buildingName: '위드센터', latlng: [35.13400446186596, 129.10583108004366], }, { + buildingCode: 'B0000015', buildingNumber: 'B12', buildingName: '나비센터', latlng: [35.134004748015656, 129.1063348228572], }, { + buildingCode: 'B0000005', buildingNumber: 'B13', buildingName: '충무관', latlng: [35.13498344411026, 129.10524198684462], }, { + buildingCode: 'B0000018', buildingNumber: 'B14', buildingName: '환경해양관', latlng: [35.13498225522856, 129.10634867710766], }, { + buildingCode: 'B0000006', buildingNumber: 'B15', buildingName: '자연과학1관', latlng: [35.13550268883498, 129.10543781395603], }, { + buildingCode: 'B0000064', buildingNumber: 'B21', buildingName: '가온관', latlng: [35.1339439662216, 129.10503421773143], }, { + buildingCode: 'B0000017', buildingNumber: 'B22', buildingName: '청운관', latlng: [35.13436049066275, 129.10478989598502], @@ -98,56 +113,67 @@ export const PKNU_BUILDINGS: PKNUBuildings = { activeColor: '#8FC049', buildings: [ { + buildingCode: 'B0000113', buildingNumber: 'C11', buildingName: '수산질병관리원', latlng: [35.13369791744112, 129.10868013710467], }, { + buildingCode: 'B0000019', buildingNumber: 'C12', buildingName: '장영실관', latlng: [35.13473949619139, 129.1089014835836], }, { + buildingCode: 'B0000020', buildingNumber: 'C13', buildingName: '해양공동연구관', latlng: [35.135455506575745, 129.10905796000708], }, { + buildingCode: 'B0000125', buildingNumber: 'C14', buildingName: '부경대학교 어린이집', latlng: [35.134968609898955, 129.1095911476619], }, { + buildingCode: 'B0000010', buildingNumber: 'C21', buildingName: '수산과학관', latlng: [35.133483825666744, 129.10779091303866], }, { + buildingCode: 'B0000009', buildingNumber: 'C22', buildingName: '건축관', latlng: [35.13461392516209, 129.10770616016015], }, { + buildingCode: 'B0000007', buildingNumber: 'C23', buildingName: '호연관', latlng: [35.13516362219819, 129.10770602771154], }, { + buildingCode: 'B0000008', buildingNumber: 'C24', buildingName: '자연과학2관', latlng: [35.13561514272967, 129.10766771682054], }, { + buildingCode: 'B0000120', buildingNumber: 'C25', buildingName: '인문사회경영관', latlng: [35.134130687473196, 129.1077646460986], }, { + buildingCode: 'B0000023', buildingNumber: 'C27', buildingName: '수조실험동', latlng: [35.13302052706407, 129.10735244658267], }, { + buildingCode: 'B0000049', buildingNumber: 'C28', buildingName: '아름관', latlng: [35.13297601808968, 129.1079671063524], @@ -158,41 +184,49 @@ export const PKNU_BUILDINGS: PKNUBuildings = { activeColor: '#FFC801', buildings: [ { + buildingCode: 'B0000041', buildingNumber: 'D12', buildingName: '테니스장', latlng: [35.13190780493772, 129.10618953917788], }, { + buildingCode: 'B0000025', buildingNumber: 'D13', buildingName: '대운동장', latlng: [35.132864569032215, 129.10621774816596], }, { + buildingCode: 'B0000126', buildingNumber: 'D14', buildingName: '한울관', latlng: [35.132256439236, 129.1069844702002], }, { + buildingCode: 'B0000280', buildingNumber: 'D15', buildingName: '창의관', latlng: [35.132942918173015, 129.1068924664527], }, { + buildingCode: 'B0000027', buildingNumber: 'D21', buildingName: '대학극장', latlng: [35.132302199965665, 129.10500017213562], }, { + buildingCode: 'B0000024', buildingNumber: 'D22', buildingName: '체육관', latlng: [35.13316402584487, 129.1048002278217], }, { + buildingCode: 'B0000261', buildingNumber: 'D23', buildingName: '안전관리관', latlng: [35.13230801606284, 129.1051832332297], }, { + buildingCode: 'B0000260', buildingNumber: 'D24', buildingName: '수상레저관', latlng: [35.13273884835734, 129.10476459993706], @@ -203,36 +237,43 @@ export const PKNU_BUILDINGS: PKNUBuildings = { activeColor: '#31A4E9', buildings: [ { + buildingCode: 'B0000032', buildingNumber: 'E11', buildingName: '세종1관', latlng: [35.13111642272434, 129.1050436853718], }, { + buildingCode: 'B0000123', buildingNumber: 'E12', buildingName: '세종2관', latlng: [35.13112044963247, 129.10414663049266], }, { + buildingCode: 'B0000128', buildingNumber: 'E13', buildingName: '공학1관', latlng: [35.13166260843009, 129.103170430803], }, { + buildingCode: 'B0000036', buildingNumber: 'E14', buildingName: '학술정보관', latlng: [35.13251893742042, 129.10393622427503], }, { + buildingCode: 'B0000131', buildingNumber: 'E21', buildingName: '공학2관', latlng: [35.13158970912741, 129.10256856014524], }, { + buildingCode: 'B0000114', buildingNumber: 'E22', buildingName: '장보고관', latlng: [35.133090750102795, 129.10291383413244], }, { + buildingCode: 'B0000042', buildingNumber: 'E29', buildingName: '양어장관리사', latlng: [35.13301817930939, 129.10152110317765], diff --git a/src/hooks/useBuildingInfo.ts b/src/hooks/useBuildingInfo.ts new file mode 100644 index 00000000..2c201879 --- /dev/null +++ b/src/hooks/useBuildingInfo.ts @@ -0,0 +1,53 @@ +import fetchBuildingInfo from '@apis/building-info/fetch-building-info'; +import { TotalFloorInfo } from '@type/building-info'; +import { getBuildingInfo } from '@utils/map/building-info'; +import { AxiosResponse } from 'axios'; +import { CSSProperties, useEffect, useState } from 'react'; + +interface BuildingInfo { + buildingCode: string; + buildingName: string; + latlng: [number, number]; + color: CSSProperties['color']; +} + +const useBuildingInfo = (buildingNumber: string) => { + const [floorInfo, setFloorInfo] = useState( + {} as TotalFloorInfo, + ); + + const { buildingCode, buildingName, color, latlng } = getBuildingInfo( + buildingNumber, + ) as BuildingInfo; + + const buildingInfo = { + buildingName, + color, + imgPath: `https://www.pknu.ac.kr/imageView.do?target=campus&cd=${buildingCode}`, + latlng, + }; + + useEffect(() => { + const getBuildingInfo = async () => { + try { + const response = (await fetchBuildingInfo( + buildingCode as string, + )) as AxiosResponse; + const fetchedFloorInfo = response.data; + + setFloorInfo(fetchedFloorInfo as TotalFloorInfo); + } catch (error) { + return error; + } + }; + + getBuildingInfo(); + }, []); + + return { + floorInfo, + buildingInfo, + }; +}; + +export default useBuildingInfo; diff --git a/src/hooks/useDragInfo.ts b/src/hooks/useDragInfo.ts new file mode 100644 index 00000000..686747c8 --- /dev/null +++ b/src/hooks/useDragInfo.ts @@ -0,0 +1,46 @@ +import { inrange, registDragEvent } from '@utils/map/regist-drag-event'; +import { useEffect, useState } from 'react'; + +const DEFAULT_HEIGHT = 300; +const BOUNDARY_MARGIN = 5; +const MIN_H = 80; + +interface InfoPosition { + top: number; + height: number; +} + +const useDragInfo = (boundary: React.RefObject) => { + const [{ top, height }, setPosition] = useState({ + top: -DEFAULT_HEIGHT, + height: 0, + }); + + useEffect(() => { + const boundaryInfo = boundary.current?.getBoundingClientRect(); + + if (!boundaryInfo) return; + + setPosition({ + top: boundaryInfo.height / 2, + height: boundaryInfo.height / 2, + }); + }, []); + + const handleDrag = registDragEvent((deltaY) => { + setPosition({ + top: inrange(top + deltaY, BOUNDARY_MARGIN, top + height - MIN_H), + height: inrange(height - deltaY, MIN_H, top + height - BOUNDARY_MARGIN), + }); + }, true); + + return { + currentPosition: { + top, + height, + }, + handleDrag, + }; +}; + +export default useDragInfo; diff --git a/src/hooks/useUserLocation.ts b/src/hooks/useUserLocation.ts index 3abac9b9..530a348e 100644 --- a/src/hooks/useUserLocation.ts +++ b/src/hooks/useUserLocation.ts @@ -25,6 +25,8 @@ const useUserLocation = () => { }; useEffect(() => { + if (userLocation) return; + if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(success, failed, { enableHighAccuracy: true, diff --git a/src/pages/Map/components/BuildingInfo/Boundary.tsx b/src/pages/Map/components/BuildingInfo/Boundary.tsx new file mode 100644 index 00000000..18ef6bc3 --- /dev/null +++ b/src/pages/Map/components/BuildingInfo/Boundary.tsx @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; +import React from 'react'; + +export default React.forwardRef>( + function Boundary(props, ref) { + return ; + }, +); + +const StyledBondary = styled.div` + position: relative; + min-height: 75vh; + background-color: transparent; + top: calc(100vh - 90px - 75vh); +`; diff --git a/src/pages/Map/components/BuildingInfo/BuildingInfo.tsx b/src/pages/Map/components/BuildingInfo/BuildingInfo.tsx new file mode 100644 index 00000000..2cd1df91 --- /dev/null +++ b/src/pages/Map/components/BuildingInfo/BuildingInfo.tsx @@ -0,0 +1,80 @@ +import styled from '@emotion/styled'; +import useDragInfo from '@hooks/useDragInfo'; +import React, { useRef } from 'react'; + +import Boundary from './Boundary'; +import InfoContent from './InfoContent'; + +const shouldUnmountInfo = (className: string) => { + return className === 'info-background' || className === 'info-boundary'; +}; + +interface BuildingInfoProps { + buildingNumber: string; + unmountInfo: () => void; +} + +const BuildingInfo = ({ buildingNumber, unmountInfo }: BuildingInfoProps) => { + const boundaryRef = useRef(null); + const { + currentPosition: { top, height }, + handleDrag, + } = useDragInfo(boundaryRef); + + const handleUnmount = (e: React.MouseEvent) => { + const clickedElement = e.target as HTMLElement; + const className = clickedElement.classList[0]; + + if (!shouldUnmountInfo(className)) return; + unmountInfo(); + }; + + return ( + + +
+ + + + +
+
+
+ ); +}; + +export default BuildingInfo; + +const BackGround = styled.div` + position: absolute; + min-height: calc(100vh - 90px); + max-width: 480px; + top: 0; + left: 0; + right: 0; + margin: auto; + background-color: rgba(0, 0, 0, 0.3); + z-index: 999; +`; + +const CursorContainer = styled.div` + position: absolute; + z-index: 999; + height: 30px; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: white; + cursor: n-resize; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +`; + +const Cursor = styled.div` + position: relative; + height: 1.5px; + width: 2rem; + border-radius: 30px; + background-color: black; +`; diff --git a/src/pages/Map/components/BuildingInfo/BuildingInfoToggle.tsx b/src/pages/Map/components/BuildingInfo/BuildingInfoToggle.tsx new file mode 100644 index 00000000..afa54794 --- /dev/null +++ b/src/pages/Map/components/BuildingInfo/BuildingInfoToggle.tsx @@ -0,0 +1,31 @@ +import { eventType } from '@utils/map/regist-drag-event'; +import React, { useEffect, useState } from 'react'; + +import BuildingInfo from './BuildingInfo'; + +const BuildingInfoToggle = () => { + const [buildingNumber, setBuildingNumber] = useState(''); + + const unmountInfo = () => setBuildingNumber(''); + const isInfoMounted = buildingNumber !== ''; + + useEffect(() => { + const getNumber = (e: MouseEvent | TouchEvent) => { + if (!(e.target instanceof HTMLSpanElement) || isInfoMounted) return; + + setBuildingNumber(e.target.innerText); + }; + + document.addEventListener(eventType, getNumber); + + return () => { + document.removeEventListener(eventType, getNumber); + }; + }, [buildingNumber]); + + return isInfoMounted ? ( + + ) : null; +}; + +export default BuildingInfoToggle; diff --git a/src/pages/Map/components/BuildingInfo/FloorInfo.tsx b/src/pages/Map/components/BuildingInfo/FloorInfo.tsx new file mode 100644 index 00000000..83e3ab67 --- /dev/null +++ b/src/pages/Map/components/BuildingInfo/FloorInfo.tsx @@ -0,0 +1,47 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { TotalFloorInfo } from '@type/building-info'; +import React from 'react'; + +import FloorInfoContent from './FloorInfoContent'; + +interface FloorInfoProps { + floorInfo: TotalFloorInfo | Record; +} + +const FloorInfo = ({ floorInfo }: FloorInfoProps) => { + if (Object.keys(floorInfo).length === 0) { + return <>; + } + + const { basement, ground, rooftop } = floorInfo; + + return ( +
+ + 층별 안내 + + + + + +
+ ); +}; + +export default FloorInfo; + +const BoundaryLine = styled.hr` + height: 1px; + background-color: #ededed; + border: none; +`; diff --git a/src/pages/Map/components/BuildingInfo/FloorInfoContent.tsx b/src/pages/Map/components/BuildingInfo/FloorInfoContent.tsx new file mode 100644 index 00000000..5e222136 --- /dev/null +++ b/src/pages/Map/components/BuildingInfo/FloorInfoContent.tsx @@ -0,0 +1,94 @@ +import ToggleInfo from '@components/Common/ToggleInfo'; +import styled from '@emotion/styled'; +import { Floor, FloorInfo, Room } from '@type/building-info'; +import { formatFloorTitle } from '@utils/map/building-info'; +import React, { Fragment } from 'react'; + +interface FloorInfoContentProps { + floorType: Floor; + infoContent: FloorInfo | Record; +} + +const FloorInfoContent = ({ + floorType, + infoContent, +}: FloorInfoContentProps) => { + if (Object.keys(infoContent).length === 0) return <>; + + return ( + <> + {Object.keys(infoContent).map((floor, index) => ( + + ( + + {formatFloorTitle(floorType as Floor, floor)} + + )} + infoDesc={() => ( + + {(infoContent[floor] as Room[]).map( + ({ roomNumber, roomName }, dataIndex) => ( + + {roomNumber} + + {roomName} + + ), + )} + + )} + /> + + + ))} + + ); +}; + +export default FloorInfoContent; + +const FloorText = styled.span` + font-size: 1.3rem; + font-weight: bold; +`; + +const RoomInfoContainer = styled.section` + border: 1px solid #e5e5e5; + + & :last-child { + border-bottom: none; + } +`; + +const RoomInfo = styled.div` + padding: 0.6rem; + height: 2rem; + border-bottom: 1px solid #e5e5e5; + display: flex; + align-items: center; + font-size: 0.9rem; +`; + +const RoomNumber = styled.span` + width: 25%; + margin-right: 0.7rem; +`; + +const RoomName = styled.span` + width: 75%; + line-height: 1.4; +`; + +const Seperator = styled.div` + width: 1px; + height: 3.2rem; + background-color: #e5e5e5; + margin-right: 1rem; +`; + +const BoundaryLine = styled.hr` + height: 1px; + background-color: #ededed; + border: none; +`; diff --git a/src/pages/Map/components/BuildingInfo/InfoContent.tsx b/src/pages/Map/components/BuildingInfo/InfoContent.tsx new file mode 100644 index 00000000..5f97d9c8 --- /dev/null +++ b/src/pages/Map/components/BuildingInfo/InfoContent.tsx @@ -0,0 +1,122 @@ +import Button from '@components/Common/Button'; +import Icon from '@components/Common/Icon'; +import Image from '@components/Common/Image'; +import Modal from '@components/Common/Modal'; +import { MODAL_BUTTON_MESSAGE } from '@constants/modal-messages'; +import TOAST_MESSAGES from '@constants/toast-message'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import useBuildingInfo from '@hooks/useBuildingInfo'; +import useModals from '@hooks/useModals'; +import useToasts from '@hooks/useToast'; +import useUserLocation from '@hooks/useUserLocation'; +import { THEME } from '@styles/ThemeProvider/theme'; +import { forrmatRoutingUrl } from '@utils/map/building-info'; +import { hasLocationPermission } from '@utils/map/user-location'; +import openLink from '@utils/router/openLink'; +import React, { CSSProperties } from 'react'; + +import FloorInfo from './FloorInfo'; + +interface InfoContentProps { + buildingNumber: string; +} + +const InfoContent = ({ buildingNumber }: InfoContentProps) => { + const userLocation = useUserLocation(); + const { openModal } = useModals(); + const { addToast } = useToasts(); + const { + floorInfo, + buildingInfo: { buildingName, imgPath, color, latlng }, + } = useBuildingInfo(buildingNumber); + + const buildingLabel = `${buildingNumber} ${buildingName}`; + + const handleRoutingModal = () => { + if (!hasLocationPermission(userLocation)) { + addToast(TOAST_MESSAGES.SHARE_LOCATION); + return; + } + + const openUrl = forrmatRoutingUrl(userLocation, latlng, buildingName); + + openModal( + + + + openLink(openUrl)} + /> + , + ); + }; + + return ( + + + + {buildingLabel} + + + + + + + ); +}; + +// TODO : memo를 사용한 경우와 그렇지 않은 경우 렌더링 속도 비교하기 +export default React.memo(InfoContent); + +const Wrapper = styled.section` + position: absolute; + overflow-y: scroll; + height: 100%; + width: 100%; + background-color: white; + cursor: move; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +`; + +const ContentContainer = styled.div` + position: relative; + padding: 40px 30px 0px 30px; +`; + +const ContentHeader = styled.header` + padding: 0px 0px 20px 0px; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + row-gap: 1.2rem; +`; + +const BuildingLabel = styled.span<{ color: CSSProperties['color'] }>` + color: ${({ color }) => color}; + font-size: 1.2rem; + font-weight: bold; +`; + +const ButtonText = styled.span` + color: ${THEME.TEXT.BLACK}; + font-size: 1rem; +`; diff --git a/src/pages/Map/components/MapHeader.tsx b/src/pages/Map/components/MapHeader.tsx index 7a1f1cc6..21c3ce5e 100644 --- a/src/pages/Map/components/MapHeader.tsx +++ b/src/pages/Map/components/MapHeader.tsx @@ -8,7 +8,7 @@ import useOverlays from '@hooks/useOverlays'; import useToasts from '@hooks/useToast'; import { THEME } from '@styles/ThemeProvider/theme'; import { BuildingType, PKNUBuilding } from '@type/map'; -import getBuildingInfo from '@utils/map/get-building-info'; +import { getBuildingSearchResult } from '@utils/map/building-info'; import React, { useRef } from 'react'; const MapHeader = () => { @@ -36,7 +36,7 @@ const MapHeader = () => { return; } - const searchResult = getBuildingInfo(inputRef.current?.value); + const searchResult = getBuildingSearchResult(inputRef.current?.value); if (!searchResult) { addToast(MODAL_MESSAGE.ALERT.SEARCH_FAILED); return; diff --git a/src/pages/Map/components/PknuMap.tsx b/src/pages/Map/components/PknuMap.tsx index 672e4d43..2009cdfe 100644 --- a/src/pages/Map/components/PknuMap.tsx +++ b/src/pages/Map/components/PknuMap.tsx @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; import useMap from '@hooks/useMap'; import useModals from '@hooks/useModals'; import useUserLocation from '@hooks/useUserLocation'; -import { isUserInShcool } from '@utils/map'; +import { isUserInShcool } from '@utils/map/user-location'; import React, { useEffect } from 'react'; import { handleMapBoundary } from '../handlers'; diff --git a/src/pages/Map/components/RefreshButtons.tsx b/src/pages/Map/components/RefreshButtons.tsx index 49303995..4ec6f514 100644 --- a/src/pages/Map/components/RefreshButtons.tsx +++ b/src/pages/Map/components/RefreshButtons.tsx @@ -7,7 +7,10 @@ import useToasts from '@hooks/useToast'; import useUserLocation from '@hooks/useUserLocation'; import { THEME } from '@styles/ThemeProvider/theme'; import { Location } from '@type/map'; -import { hasLocationPermission, isUserInShcool } from '@utils/map'; +import { + hasLocationPermission, + isUserInShcool, +} from '@utils/map/user-location'; import React from 'react'; const RefreshButtons = () => { @@ -39,7 +42,7 @@ const RefreshButtons = () => { return ( handleMapCenter(userLocation)}> - + handleMapCenter(PKNU_MAP_CENTER)}> @@ -51,13 +54,13 @@ const RefreshButtons = () => { export default RefreshButtons; const IconContainer = styled.div` - width: 95%; position: absolute; top: 6rem; - z-index: 999; + right: 0; + z-index: 3; padding: 1rem; + width: 10%; gap: 10px; - display: flex; flex-direction: column; align-items: flex-end; diff --git a/src/pages/Map/handlers/index.ts b/src/pages/Map/handlers/index.ts index 1849389b..0175030a 100644 --- a/src/pages/Map/handlers/index.ts +++ b/src/pages/Map/handlers/index.ts @@ -1,2 +1 @@ -export { default as getHaversineDistance } from './distance'; export { default as handleMapBoundary } from './boundary'; diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index 1f571044..81ea7785 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -1,4 +1,5 @@ import Map from '@components/Providers/MapProvider'; +import { useEffect } from 'react'; declare global { interface Window { @@ -7,12 +8,21 @@ declare global { } const MapPage = () => { + useEffect(() => { + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = 'unset'; + }; + }, []); + return ( + ); }; diff --git a/src/utils/map/building-info.ts b/src/utils/map/building-info.ts new file mode 100644 index 00000000..39c9771c --- /dev/null +++ b/src/utils/map/building-info.ts @@ -0,0 +1,65 @@ +import { PKNU_BUILDINGS } from '@constants/pknu-map'; +import { Floor } from '@type/building-info'; +import { BuildingType, Location } from '@type/map'; + +export const getBuildingSearchResult = ( + keyword: string, +): [BuildingType, number] | undefined => { + const formattedKeyword = keyword.replaceAll(' ', '').toUpperCase(); + + for (const buildingType of Object.keys(PKNU_BUILDINGS)) { + const index = PKNU_BUILDINGS[ + buildingType as BuildingType + ].buildings.findIndex( + (PKNU_BUILDING) => + PKNU_BUILDING.buildingName === formattedKeyword || + PKNU_BUILDING.buildingNumber === formattedKeyword, + ); + + if (index !== -1) return [buildingType as BuildingType, index]; + } + + return; +}; + +export const getBuildingInfo = (buildingNumber: string) => { + const buildingTypes = Object.keys(PKNU_BUILDINGS) as BuildingType[]; + + for (const type of buildingTypes) { + for (const building of PKNU_BUILDINGS[type].buildings) { + if (building.buildingNumber !== buildingNumber) continue; + + return { + buildingCode: building.buildingCode, + buildingName: building.buildingName, + color: PKNU_BUILDINGS[type].activeColor, + latlng: building.latlng, + }; + } + } +}; + +export const forrmatRoutingUrl = ( + userLocation: Location | null, + latlng: [number, number], + buildingName: string, +): string => { + if (!userLocation) return ''; + + const { LAT, LNG } = userLocation; + const [lat, lng] = latlng; + + const kakaoMapAppURL = `kakaomap://route?sp=${LAT},${LNG}&ep=${lat},${lng}`; + const kakaoMapWebURL = `https://map.kakao.com/link/from/현위치,${LAT},${LNG}/to/${buildingName},${lat},${lng}`; + const isKakaoMapInstalled = /KAKAOMAP/i.test(navigator.userAgent); + const openUrl = isKakaoMapInstalled ? kakaoMapAppURL : kakaoMapWebURL; + + return openUrl; +}; + +export const formatFloorTitle = (type: Floor, floor: string): string => { + if (type === 'basement') return `B${floor}F`; + if (type === 'ground') return `${floor}F`; + if (type === 'rooftop') return `R${floor}F`; + return ''; +}; diff --git a/src/utils/map/check-location.ts b/src/utils/map/check-location.ts deleted file mode 100644 index e23f37de..00000000 --- a/src/utils/map/check-location.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { getHaversineDistance } from '@pages/Map/handlers'; - -const isUserInShcool = (lat: number, lng: number) => { - const maxDistance = 450; - return getHaversineDistance(lat, lng) <= maxDistance; -}; - -export default isUserInShcool; diff --git a/src/utils/map/get-building-info.ts b/src/utils/map/get-building-info.ts deleted file mode 100644 index 4a626bb6..00000000 --- a/src/utils/map/get-building-info.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { PKNU_BUILDINGS } from '@constants/pknu-map'; -import { BuildingType } from '@type/map'; - -const getBuildingInfo = ( - keyword: string, -): [BuildingType, number] | undefined => { - const formattedKeyword = keyword.replaceAll(' ', '').toUpperCase(); - - for (const buildingType of Object.keys(PKNU_BUILDINGS)) { - const index = PKNU_BUILDINGS[ - buildingType as BuildingType - ].buildings.findIndex( - (PKNU_BUILDING) => - PKNU_BUILDING.buildingName === formattedKeyword || - PKNU_BUILDING.buildingNumber === formattedKeyword, - ); - if (index !== -1) return [buildingType as BuildingType, index]; - } - - return; -}; - -export default getBuildingInfo; diff --git a/src/utils/map/index.ts b/src/utils/map/index.ts deleted file mode 100644 index 5bf0bc07..00000000 --- a/src/utils/map/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as hasLocationPermission } from './location-permission'; -export { default as isUserInShcool } from './check-location'; diff --git a/src/utils/map/location-permission.ts b/src/utils/map/location-permission.ts deleted file mode 100644 index 3f7a01c8..00000000 --- a/src/utils/map/location-permission.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NO_PROVIDE_LOCATION } from '@constants/pknu-map'; -import { Location } from '@type/map'; - -const hasLocationPermission = (location: Location | null) => { - return ( - location && JSON.stringify(location) !== JSON.stringify(NO_PROVIDE_LOCATION) - ); -}; - -export default hasLocationPermission; diff --git a/src/utils/map/regist-drag-event.ts b/src/utils/map/regist-drag-event.ts new file mode 100644 index 00000000..b45d78de --- /dev/null +++ b/src/utils/map/regist-drag-event.ts @@ -0,0 +1,59 @@ +export const inrange = (v: number, min: number, max: number) => { + if (v < min) return min; + if (v > max) return max; + return v; +}; + +export const isTouchScreen = + typeof window !== 'undefined' && + window.matchMedia('(hover: none) and (pointer: coarse)').matches; + +export const eventType = isTouchScreen ? 'touchstart' : 'click'; + +export const registDragEvent = ( + onDragChange: (deltaY: number) => void, + stopPropagation?: boolean, +) => { + if (isTouchScreen) { + return { + onTouchStart: (touchEvent: React.TouchEvent) => { + if (stopPropagation) touchEvent.stopPropagation(); + + const touchMoveHandler = (moveEvent: TouchEvent) => { + if (moveEvent.cancelable) moveEvent.preventDefault(); + + const deltaY = + moveEvent.touches[0].screenY - touchEvent.touches[0].screenY; + onDragChange(deltaY); + }; + + const touchEndHandler = () => { + document.removeEventListener('touchmove', touchMoveHandler); + }; + + document.addEventListener('touchmove', touchMoveHandler, { + passive: false, + }); + document.addEventListener('touchend', touchEndHandler, { once: true }); + }, + }; + } + + return { + onMouseDown: (clickEvent: React.MouseEvent) => { + if (stopPropagation) clickEvent.stopPropagation(); + + const mouseMoveHandler = (moveEvent: MouseEvent) => { + const deltaY = moveEvent.screenY - clickEvent.screenY; + onDragChange(deltaY); + }; + + const mouseUpHandler = () => { + document.removeEventListener('mousemove', mouseMoveHandler); + }; + + document.addEventListener('mousemove', mouseMoveHandler); + document.addEventListener('mouseup', mouseUpHandler, { once: true }); + }, + }; +}; diff --git a/src/pages/Map/handlers/distance.ts b/src/utils/map/user-location.ts similarity index 58% rename from src/pages/Map/handlers/distance.ts rename to src/utils/map/user-location.ts index c1801341..a6682e4f 100644 --- a/src/pages/Map/handlers/distance.ts +++ b/src/utils/map/user-location.ts @@ -1,4 +1,5 @@ -import { PKNU_MAP_CENTER } from '@constants/pknu-map'; +import { NO_PROVIDE_LOCATION, PKNU_MAP_CENTER } from '@constants/pknu-map'; +import { Location } from '@type/map'; const degreeToRadian = (deg: number) => deg * (Math.PI / 180); @@ -14,10 +15,20 @@ const getHaversineDistance = (lat: number, lng: number) => { Math.cos(degreeToRadian(lat)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const angularDistance = 2 * Math.atan2(Math.sqrt(halfSideLength), Math.sqrt(1 - halfSideLength)); return R * angularDistance; }; -export default getHaversineDistance; +export const hasLocationPermission = (location: Location | null) => { + return ( + location && JSON.stringify(location) !== JSON.stringify(NO_PROVIDE_LOCATION) + ); +}; + +export const isUserInShcool = (lat: number, lng: number) => { + const maxDistance = 450; + return getHaversineDistance(lat, lng) <= maxDistance; +};