기술 스택: React.js

배포 링크: https://kosy0907.github.io/EV-map/

소스코드 링크: https://github.com/kosy0907/EV-map (Private)

 

소개

서울시 내 지하철역 근처의 교통약자용 엘리베이터 위치를 지도에 표시해주는 웹 사이트이다.

이 프로젝트는 2021년, 내가 다리를 다쳤을 때 지하철 이용에 불편을 느낀 경험을 살려 진행했다.

 

기능

- 지하철역 근처의 교통약자용 엘리베이터 위치 표시

- 역 이름 검색

 

개발기간

2022.01.21 ~ 2022.01.30

 

사용 API

Geolocation API

 

디자인 -> 구현 순서로 진행

figma

지도로 화면을 채우고 search form을 넣으면 디자인은 끝이다.

  • Map Component
    • Search Component: 검색창
    • KaKaoMapScript Component: API와 지도

 

1. 데이터 가져오기

공공데이터포털의 서울특별시 지하철역 엘리베이터 위치정보 파일을 사용했다.

해당 데이터는 서울시 내 지하철역 주변의 교통약자용 엘리베이터 정보를 제공한다.

https://www.data.go.kr/tcs/dss/selectFileDataDetailView.do?publicDataPk=15098148 

 

서울특별시_지하철역 엘리베이터 위치정보_20220111

서울시 내 지하철역 주변의 교통약자용 엘리베이터 위치를 제공합니다. 좌표계는 WGS84을 사용합니다.<br/>노드링크 유형, 노드 WKT, 노드 ID, 노드 유형 코드, 시군구코드, 시군구명, 읍면동코드, 읍

www.data.go.kr

가져온 데이터는 src/data/elevatorLocation.js에 JSON 객체 형태로 저장한다.

// elevatorLocation.js
export const elevatorLocation =
{
  "DESCRIPTION": { "NODE_WKT": "노드 WKT", "SW_NM": "지하철역명", "SW_CD": "지하철역코드", "SGG_NM": "시군구명", "SGG_CD": "시군구코드", "NODE_ID": "노드 ID", "NODE_CODE": "노드 유형 코드", "EMD_NM": "읍면동명", "TYPE": "노드링크 유형", "EMD_CD": "읍면동코드" },
  "DATA": [
          { "sgg_cd": "1111000000", "emd_nm": "종로2가", "node_code": "0", "emd_cd": "1111013800", "node_wkt": "POINT(126.98397877663305 37.57010684982412)", "sgg_nm": "종로구", "type": "NODE", "sw_nm": null, "sw_cd": null, "node_id": 86879 },
          { "sgg_cd": "1111000000", "emd_nm": "숭인동", "node_code": "0", "emd_cd": "1111017500", "node_wkt": "POINT(127.01744971746365 37.57329704981851)", "sgg_nm": "종로구", "type": "NODE", "sw_nm": "동묘앞", "sw_cd": "268", "node_id": 212659 }
}

 

2. 틀 제작

2-1. 프로젝트 생성

create react-app ev-map

2-2. 지도를 표시할 Map Component 작성

// Map.js

import React from 'react';

const Map = () => {
    return (
        <div>
            <Search/> // 검색창
            <KakaoMapScript/> // api & 지도
        </div>
    );
};

export default Map;

 

3. api 가져오기

KaKao Map api를 이용할 것이므로 KaKao Developers에 로그인 후, 애플리케이션을 추가한다.

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

애플리케이션을 등록하면 api key를 발급받을 수 있다.

보안을 위해 .env 파일을 생성한 후, api key를 넣자.

// .env
REACT_APP_KAKAO_KEY = <api key>

index.html에 script 추가도 잊지 말자.

<!DOCTYPE html>
...
    <script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%REACT_APP_KAKAO_KEY%"></script>
...
</html>

kaKaoScript.js 파일을 생성하고 api를 가져온다.

// kaKaoScript.js
import react from "react";
import styled from "styled-components";
const { kakao } = window;

const MapDiv = styled.div`
    width: '100%',
    height: '100vh'
`

export default function KakaoMapScript({ searchText }) {

    useEffect(() => {
        // kakao map
        const container = document.getElementById('kakao-map');
        const options = {
            center: new kakao.maps.LatLng(37.566826, 126.9786567),
            level: 3
        };
        const map = new kakao.maps.Map(container, options);

    return (
        <MapDiv id="kakao-map" style={{ width: "100%", height: "100vh" }} />
    )
}

 

 

4. 지도에 marker 추가

4-1. 현재 내 위치 표시

geolocation api를 사용하여 현재 내 위치를 지도에 표시할 수 있다.

PC 환경에서는 내 위치를 제대로 잡지 못하지만, 모바일 환경에서는 정확하게 잡는 편이다.

// kakaoScript.js
	...
    	const getCurrentPos = () => {
            if (navigator.geolocation) {
                navigator.geolocation.getCurrentPosition(
                    function (position) {
                        var lat = position.coords.latitude;
                        var lon = position.coords.longitude;
                        // 내 현재 위치를 kakao map에 표시한다.
                        var currentPos = new kakao.maps.LatLng(lat, lon);
                        var marker = new kakao.maps.Marker({
                            map: map,
                            position: currentPos,
                            image: new kakao.maps.MarkerImage(
                                "https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/markerStar.png",
                                new kakao.maps.Size(24, 35),
                                { offset: new kakao.maps.Point(13, 35) }
                            )
                        });
                        map.setCenter(currentPos);
                    },
                    // 위치를 가져올 수 없는 경우, 카카오 본사로 위치를 잡는다.
                    function (err) {
                        options.center = new kakao.maps.LatLng(37.566826, 126.9786567);
                        const map = new kakao.maps.Map(container, options);
                    })
            }
        }
        getCurrentPos();
        ...

4-2. 엘리베이터 위치 표시

markers 리스트를 생성하고 elevatorLocation.js의 데이터를 전처리해서 위도(lat), 경도(lng)를 뽑아내서 kaka.maps.Marker의 position에 넣는다.

useEffect(()=>{
...
       const markers = [];
        for (var i = 0; i < elevatorLocation.DATA.length; i++) {
            var point = elevatorLocation.DATA[i].node_wkt
                .split("(")[1]
                .split(")")[0]
                .split(" ");

            var latlng = new kakao.maps.LatLng(
                parseFloat(point[1]),
                parseFloat(point[0])
            );

            var marker = new kakao.maps.Marker({
                position: latlng,
                clickable: false,
            });
            markers.push(marker);
        }
        return markers;
    }, []);
...
})

 

5. 검색 기능 추가

5-1. Map.js 수정

Search.js에서 searchText state를 업데이트 할 수 있도록 setSearchText를 props로 전달한다.

KaKaoMapScript Componenent에는 searchText를 props로 전달한다.

// Map.js

import React, { useEffect, useState } from 'react';
import KakaoMapScript from './kakaoScript';
import Search from './Search';
const { kakao } = window;

const Map = () => {
    const [searchText, setSearchText] = useState('');

    return (
        <div>
            <Search setSearchText={setSearchText} searchText={searchText} />
            <KakaoMapScript searchText={searchText} />
        </div>
    );
};

export default Map;

5-2. Search Component 작성

import React, { useState } from 'react';
import styled from 'styled-components';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSearch } from '@fortawesome/free-solid-svg-icons';

const SearchContainer = styled.div`
    ...
`

const Form = styled.form`
    ...
`

const Input = styled.input`
    ...
`

const SearchBtn = styled.button`
    ...
`

const Search = (props) => {
    const [inputText, setInputText] = useState('');
	// InputText를 입력한 value로 설정
    const handleInputChange = (e) => {
        e.preventDefault();
        setInputText(e.target.value);
    }
	// SearchText를 inputText로 설정
    const handleSubmit = (e) => {
        e.preventDefault();
        props.setSearchText(inputText);
    }
	// input 창이 비어있는 경우
    const textChecker = (e) => {
        if (inputText === '') {
            alert('검색어를 입력해주세요!');
        }
    }

    return (
        <SearchContainer>
            <Form onSubmit={handleSubmit}>
                <Input type='text' value={inputText} onChange={handleInputChange} placeholder='역명을 입력하세요! ex) 혜화' />
                <SearchBtn type='submit' onClick={textChecker}><FontAwesomeIcon icon={faSearch} /></SearchBtn>
            </Form>
        </SearchContainer>
    );
}

export default Search;

여기서 검색창에 텍스트를 입력할 때마다 속도가 느려지는 문제가 발생했다.

텍스트를 입력할 때마다 위치를 나타내는 marker가 제렌더링 되는 것이 원인이었다.

useEffect 안에 넣지 말고 useMemo를 사용하여 해결한다.

// kakaoScript.js
...
const markers = useMemo(() => {
        const markers = [];
        for (var i = 0; i < elevatorLocation.DATA.length; i++) {
            var point = elevatorLocation.DATA[i].node_wkt
                .split("(")[1]
                .split(")")[0]
                .split(" ");

            var latlng = new kakao.maps.LatLng(
                parseFloat(point[1]),
                parseFloat(point[0])
            );

            var marker = new kakao.maps.Marker({
                position: latlng,
                clickable: false,
            });
            markers.push(marker);
        }
        return markers;
    }, []);
...
useEffect(() => {
            // kakao map
            const container = document.getElementById('kakao-map');
            const options = {
                center: new kakao.maps.LatLng(37.566826, 126.9786567),
                level: 3
            };
            const newMap = new kakao.maps.Map(container, options);
            setMap(newMap);

            for (var j = 0; j < markers.length; j++) {
                markers[j].setMap(newMap);
            }
          )
...

여기서 문제가 하나 더 생긴다.

Cannot read property 'map' of undefined

map이 완전히 로드되지 않은 상태일 때, 함수에서 map을 호출하기 때문에 발생하는 error이다.

마커를 설정하는 코드를 별도의 useMemo 후크로 이동하여 구성 요소를 마운트할 때 한 번만 호출하도록 수정한다.

또한, map이 로드되면 함수를 호출하도록 kakao.maps.load() 안에 api를 호출 함수를 넣는다.

    ...
    const markers = useMemo(() => {
        const markers = [];
        for (var i = 0; i < elevatorLocation.DATA.length; i++) {
            var point = elevatorLocation.DATA[i].node_wkt
                .split("(")[1]
                .split(")")[0]
                .split(" ");

            var latlng = new kakao.maps.LatLng(
                parseFloat(point[1]),
                parseFloat(point[0])
            );

            var marker = new kakao.maps.Marker({
                position: latlng,
                clickable: false,
            });
            markers.push(marker);
        }
        return markers;
    }, []);
    ...
useEffect(() => {
        kakao.maps.load(() => {
            ...
	// searchText가 있을 때, elevatorLocation DATA의 sw_nm과 searchText가 같으면 해당하는 위도, 경도를 찾아 지도 이동
         	if (searchText) {
                const filteredLocations = elevatorLocation.DATA.filter(location => {
                    const regex = new RegExp(searchText.replace('역', '') + '역?');
                    if (location.sw_nm === null) {
                        return regex.test(location.emd_nm);
                    } else {
                        return regex.test(location.sw_nm);
                    }
                });

                if (filteredLocations.length > 0) {
                    const regex = /\d+\.\d+/g;
                    const filteredPoint = filteredLocations[0].node_wkt.match(regex);
                    console.log(filteredPoint);
                    const moveLotation = new kakao.maps.LatLng(parseFloat(filteredPoint[1]), parseFloat(filteredPoint[0]));
                    console.log(moveLotation);
                    kakao.maps.event.addListener(newMap, 'tilesloaded', function () {
                        newMap.panTo(moveLotation);
                    }
                    )
                } else {
                    alert('결과를 찾을 수 없습니다!')
                }
        })
    }, [searchText, markers]);

 

6. 배포

배포는 GitHub Pages를 이용했다.

GitHub Pages - GitHub Repository의 프로젝트를 호스팅해주는 기능

배포 방법은 다음과 같다.

1. 레포지토리(public)를 생성하고 프로젝트를 commit, push

2. 배포하고 싶은 프로젝트에 gh-pages를 설치한다.

npm install gh-pages --save-dev
or
yarn add gh-pages --save-dev

3. package.json 수정

  • "homepage": "https://<git ID>.github.io/<project name>"
  • "predeploy": "npm run build"m
  • "deploy": "gh-pages -d build"
{
  "homepage": "https://kosy0907.github.io/EV-map",
  "name": "ev-map",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    ...
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "predeploy": "npm run build",
    "deploy": "gh-pages -d build"
  },
  ...
npm run deploy

배포가 완료되면, Environment가 active 상태로 설정된다.

Environments를 클릭하면 배포 히스토리를 확인할 수 있다.

View deployment를 클릭해보자.

 

'React.js > Project' 카테고리의 다른 글

포트폴리오 웹사이트 제작기  (0) 2023.04.08

사용 프레임워크: React.js

배포 링크: https://kosy0907.github.io/Portfolio/

소스코드 링크: https://github.com/kosy0907/Portfolio (Public)

타입스크립트로 제작할까 하다가 TS는 아직 부족한 거 같아서 그냥 JS로 제작

언제나 그랬듯이 디자인 -> 구현 순서로 진행

 

디자인 못해서 여러 번 수정했다(지금도 수정중)
하지만 재밌죠?

Section은 3개로 나눴다.

  • Section1(Home) - Parallax Effect, Scroll Effect, Bounce Animation
  • Section2(About) - FadeIn Animation
  • Section3(Project) - Click-FadeIn Animation

시작은 npx create-react-app

미리 보는 component

 

1. Custom Cursor

웹사이트를 서치하다보면 가끔 둥글거나 다른 모양이 적용된 Cursor를 확인할 수 있다.

내심 어떻게 구현한 건지 궁금했는데 웹 사이트를 제작하면서 확실하게 알게 되었다.

import React, { useEffect, useRef } from 'react';
import './Cursor.css';

const Cursor = () => {
    const mainCursorRef = useRef(null);
    const subCursorRef = useRef(null);

    useEffect(() => {
        const onMouseMove = (e) => {
            const { clientX, clientY } = e

            mainCursorRef.current.style.transform = `translate3d(${clientX}px, ${clientY}px, 0)`;
            subCursorRef.current.style.transform = `translate3d(${clientX}px, ${clientY}px, 0)`;
            subCursorRef.current.style.transition = `all 0.15s`;
        }
        document.addEventListener('mousemove', onMouseMove);

        return () => {
            document.removeEventListener('mousemove', onMouseMove);
        }
    }, [])

    return (
        <div>
            <div className='mainCursor' ref={mainCursorRef} />
            <div className='subCursor' ref={subCursorRef} />
        </div>
    );
}

export default Cursor;

1. div에 useRef Hook을 사용해 mainCursorRef, subCursorRef를 걸고

2. useEffect Hook으로 마우스가 이동할 때마다 커서의 위치(clientX, clientY)를 업데이트 하는 이벤트 리스너를 추가한다. 

3. subCursor(조금 더 큰 원)이 커서를 천천히 따라올 수 있도록 transition animation을 추가한다.

이렇게 하면 Component가 마운트되면 이벤트 리스너가 추가되고 언마운트되면 제거된다.

Custom Cursor 완성!

 

2. Section1 - Home

Parallax Effect를 넣고 싶어서 직접 제작한 픽셀아트 gif의 레이어를 밤하늘, 별, 건물, 산 등등으로 나눴다.

아이패드 없었으면 어쩔 뻔했어

예전에는 Steam에서 구매한 Aseprite 썼는데 아이패드로 하는 게 훨씬 편하고 빠르다.

진작에 프로크리에이트 쓸걸

잠깐! 포토샵을 사용한 이유는?

픽셀아트는 캔버스 사이즈가 작기 때문에 (보통 200x200) 막 늘려서 웹페이지에 적용했다가는 이미지가 깨질 수 있다.

포토샵으로 레이어 하나하나 크기를 늘려준다. 귀찮은 작업일 수 있지만 픽셀아트는 이것마저 재밌다.

image 폴더에 쏙

// Section1
import React, { useEffect, useState } from 'react';
...

function Section1(props) {
    // parallax
    const [position, setPosition] = useState(0);
    
    const onScroll = () => {
        setPosition(window.scrollY);
    }
    const toAbout = () => {
        props.aboutRef.current?.scrollIntoView({ behavior: 'smooth' });
    }

    useEffect(() => {
        window.addEventListener('scroll', onScroll);
        return () => {
            window.removeEventListener('scroll', onScroll);
        }
    }, []);

    return (
        <div className='home'>
            <div className='introBg'>
                <div className='intro bg1' style={{ backgroundPositionY: position }} />
                <div className='intro bg2' style={{ backgroundPositionY: position }} />
                <div className='intro bg3' style={{ backgroundPositionY: position / 2 }} />
                <div className='intro bg4' style={{ backgroundPositionY: position / 2 }} />
                <div className='intro bg5' style={{ backgroundPositionY: position / 3 }} />
                <div className='intro bg6' style={{ backgroundPositionY: position / 10 }} />

                <div className='intro cover'>
                    <div className='text'>
                    // Vertical Scrolling Text
                        <div className='scrollContainer'>
                            <div className='scrollBox'>
                                ...
                            </div> 
                        </div >

                        <div className='fixed-container'>
                            ...
                        </div>
                        // Arrow Bounce Animation
                        <div style={{ marginTop: "3rem" }}>
                            <FontAwesomeIcon className='bounceArrow' icon={faAngleDown} size="2x" />
                        </div>
                       	<div className='toAboutBtn' onClick={toAbout}>About Me</div>
                    </div >
                </div >

            </div >
        </div >
    );
}

export default Section1;

intro bg1~bg6은 각각 위에서 만든 픽셀아트 레이어들을 담는 div이다.

intro cover로 전체 화면을 어둡게 덮고 그 위에 Scrolling text 애니메이션과 Bounce Animation을 적용했다.

특정 환경에서는 PositionY의 값이 변경될 수 있다.

toAboutBtn은 클릭 시 바로 About Section으로 스크롤 되도록 구현했다.

레이어 나눈 보람이 있다

// Navbar
import React, { forwardRef } from 'react';
import './Navbar.css';

function Navbar(props) {

    const HomeClick = () => {
        props.setNavState(1);
        window.scrollTo({ top: 0, behavior: 'smooth' });
    }

    const AboutClick = () => {
        props.setNavState(2);
        props.aboutRef.current?.scrollIntoView({ behavior: 'smooth' });
    }

    const ProjectClick = () => {
        props.setNavState(3);
        props.projectRef.current?.scrollIntoView({ behavior: 'smooth' });
    }

    return (
        <nav>
            <ul>
                <li className='navBtn' onClick={HomeClick}>HOME<span>.</span></li>
                <li className='navBtn' onClick={AboutClick}>ABOUT<span>.</span></li>
                <li className='navBtn' onClick={ProjectClick}>PROJECT<span>.</span></li>
            </ul>
        </nav>
    );
}
export default forwardRef(Navbar);

App.js(부모 컴포넌트)에서 props를 통해 setNavState, 다른 Section 컴포넌트(About, Project)를 가져온다.

Navbar에 있는 버튼을 클릭할 때마다 각각 HomeClick, AboutClick, ProjectClick 함수가 호출되어 부드럽게 스크롤 되도록 구현했다. 

 

3. Section2 - About

간단한 소개와 Education, Certificate, 사용 가능한 Skill을 담은 컴포넌트이다.

Education과 Certificate를 넣는 게 맞는 선택일까 고민했지만 그냥 넣었다.

import React, { forwardRef, useEffect } from 'react';
import './Section2.css';

function Section2(props, aboutRef) {

    useEffect(() => {
        const observer = new IntersectionObserver(
            entries => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        entry.target.classList.add('fadeIn');
                    } else {
                        entry.target.classList.remove('fadeIn');
                    }
                });
            },
            { threshold: 0.2 }
        );

        const targets = document.querySelectorAll('.fadeTarget');
        targets.forEach(target => observer.observe(target));

        return () => observer.disconnect();
    }, []);

    return (
        <div className='about' ref={aboutRef}>
            <div className='container'>
                <div className='title fadeTarget'>
                    <p>About Me</p>
                </div>
                <div className='aboutContent fadeTarget'>
                    ...
                </div>
            </div>
        </div>
    );
}

export default forwardRef(Section2);

Home에서 About으로 스크롤 시 fade in 애니메이션으로 나타나게끔 구현했다.

여기서는 Intersection Observer를 사용했다. 

1. 컴포넌트가 마운트될 때 IntersectionObserver를 생성하고

2. Observer가 관찰할 수 있도록 애니메이션을 줄 div에 'fadeTarget' 클래스를 추가한다.

3. 여기서 style에 animation delay를 줘서 애니메이션을 늦출 수 있다.

4. css에 fadeIn 애니메이션을 설정하면 끝!

 

4. Section3 - Project

Navbar의 Project 버튼을 클릭하거나 About Section에서 밑으로 스크롤하면 나타난다.

projectTitle을 Click했을 때, 프로젝트 정보를 담은 card가 fade in animation으로 cardContainer에 나타나게 구현하고 싶었다.

// section3(project)
	...
	const handleItemClick = (item) => {
        if (selectedItem === null) {
            setSelectedItem({ ...item, show: true });
            setIsCardVisible(true);
        } else if (selectedItem.id !== item.id) {
            setSelectedItem({ ...item, show: true });
            setIsCardVisible(false);
            setTimeout(() => setIsCardVisible(true), 500);
        }
    };
    ...
    return (
    	...
        <div className='projectTitle'>
        ...
        <div className='cardContainer>
        ...
    )
// Card.js
import React, { useEffect, useState } from 'react';
import './Card.css';

function Card(props) {
    const { item } = props;

    const [showCard, setShowCard] = useState(false);

    useEffect(() => {
        if (item.show) {
            setTimeout(() => {
                setShowCard(true);
            }, 20);
        }
    }, [item]);

    return (
        <div className={`card ${showCard ? 'show' : ''}`}>
            <div className="cardTitle">{item.title}</div>
            <div className="cardDescription">{item.description}</div>
        </div>
    );
}

export default Card;

하지만, 이렇게 하면 다른 projectTitle을 클릭했을 때, 가끔씩 애니메이션이 적용되지 않는 문제가 있었다.

원인은 handleItemClick의 setTimeout이 문제였다.

setTimeout은 시간을 정확하게 보장하지 않기 때문에, 대신 requestAnimationFrame을 사용하는 것이 좋다.

requestAnimationFrame은 브라우저의 리플로우, 리페인트 과정을 최적화하면서 실행되기 때문에, 애니메이션 효과를 좀 더 부드럽게 적용할 수 있다.

// section3 수정
    const handleItemClick = (item) => {
        if (selectedItem === null) {
            setSelectedItem({ ...item, show: true });
            setIsCardVisible(true);
        } else if (selectedItem.id !== item.id) {
            setSelectedItem({ ...item, show: true });
            setIsCardVisible(false);
            window.requestAnimationFrame(() => setIsCardVisible(true));
        }
    };

 

5. App.js

...

function App() {
  const [scrollIndex, setScrollIndex] = useState(1);
  const [navState, setNavState] = useState(1);
  const aboutRef = useRef(null);
  const projectRef = useRef(null);

  useEffect(() => {
    if (navState === 1) {
      setScrollIndex(1);
    } else if (navState === 2) {
      setScrollIndex(2);
    } else if (navState === 3) {
      setScrollIndex(3);
    }
  }, [navState])

  useEffect(() => {
    const wheelHandler = (e) => {
      ...
    };

    document.addEventListener("wheel", wheelHandler);
    return () => {
      document.removeEventListener("wheel", wheelHandler);
    };

  }, []);

  return (
    <div className="App">
      <Cursor />
      <Navbar aboutRef={aboutRef} projectRef={projectRef}
        setNavState={setNavState} />
      <div className='section'>
        <Dots scrollIndex={scrollIndex} />
        <Section1 />
        <div className="divider" />
        <Section2 ref={aboutRef} />
        <div className="divider" />
        <Section3 ref={projectRef} />
      </div>
      <Footer />
    </div>
  );
}

export default App;

App.js에는 Navbar에 Section을 넘겨주고 컴포넌트를 리턴하도록 구현했다.

마우스 스크롤 시 부드럽게 움직이도록 wheelHandler 함수를 추가했다.

'React.js > Project' 카테고리의 다른 글

EV-map 제작기(백업)  (0) 2023.05.03

+ Recent posts