반응형

vis-timeline을 이용하여 아래와 같이 구현을 해보았습니다.

기능적으로 추가를 더 해야되지만 시간이 없어서 아래와 같이만 구현을 하였습니다.

 

 

아래는 소스 코드 입니다.

 

[React]

import React, { useEffect, useRef } from "react";
import vis from "vis-timeline";
import "vis-timeline/dist/vis-timeline-graph2d.min.css";
import "./index.scss";

const VisTimeLine = () => {
    const timelineRef = useRef(null);
    const today = new Date();
    const count = 2000;

    const SetStartTime = 9;
    const SetEndTime = 18; //24hr, 12hr, 6hr 등등 세팅 가능
    
    // 오늘 날짜를 기준으로 시작과 종료 시간을 설정
    const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), SetStartTime, 0, 0); 
    const endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), SetEndTime, 0, 0); 

    // 타임라인 옵션
    const options = {
        locale: "en",
        locales: {
            en: {},
        },        
        //showCurrentTime : false, //현재 시간 표기
        showMajorLabels : false, //최상단 날짜 표기 막기
        //showMinorLabels : false, //시간 나오지 않게
        start : startDate,
        end : endDate,
        preferZoom: true,
        moveable : false, //마우스로 이동하는 것 막기
        // verticalScroll: true,
        orientation: { axis: "top", item: "top" },
        height: "92px",
        // maxHeight: 130,            // 최대 높이
        // minHeight: 130,            // 최소 높이
        // zoomMin: 1000 * 60 * 60,
        // zoomMax: 1000 * 60 * 60 * 24 * 365,
        zoomMin: 1000 * 60 * 60, // 최소 줌 레벨 (10분)
        zoomMax: 1000 * 60 * 60 * 44, // 최대 줌 레벨 (1일)
        // groupHeightMode: "fitItems",
        groupHeightMode: "fixed", // 그룹 높이 고정
        stack: false,     //items이 시간이 겹쳐도 겹치게 볼 수 있게 설정
    };

    // 타임라인의 아이템 생성 함수
    const generateItems = (count) => {
        const intervalSeconds = 30

        return Array.from(Array(count)).map((_, index) => ({
            id: index,            
            className : Math.floor(Math.random() * 4) % 2 == 0 ? 'danger' : 'warning',
            group: Math.floor(Math.random() * 4),            
            start: new Date(
                today.getTime() + (intervalSeconds * 1000 * (index+1))
            ),
            end: new Date(
                today.getTime() + (intervalSeconds * 1000 * (index+2))
            ),
        }));
    };    

    // 타임라인의 그룹 생성 함수
    const generateGroups = () => {
        return [
            {
                id: 0,
                content: `Alarm`,
            },
            {
                id: 1,
                content: `Asystole`,
            },
            {
                id: 2,
                content: `V-FIB`,
            },
            {
                id: 3,
                content: `V-TACH`,
            },
        ];
    };

    const handlePlus = () => {
        alert('plus');
    }

    const handleMinus = () => {
        alert('plus');
    }

    const items = generateItems(count);
    const groups = generateGroups();

    useEffect(() => {
        // 타임라인 초기화
        const timeline = new vis.Timeline(
            timelineRef.current,
            items,
            groups,
            options
        );
        
        return () => {
            timeline.destroy();
        };
    }, [items, groups, options]);

    return (
        <div className="timeline-area">
            <div
                ref={timelineRef}
                id="timeline"
                className="timeline"                
            ></div>     
            <div className="control-timeline">
                <div className="time-area">
                    <span>24 hr</span>
                </div>    
                <div className="button-area">
                    <p onClick={() => handlePlus()}>+</p>
                    <p onClick={() => handleMinus()}>-</p>
                </div>
            </div>       
        </div>
    );
};

export default VisTimeLine;

 

[SCSS]

body {
    background: #000;
    font-family: sans-serif;
    font-weight: 700;
}

.timeline-area {
    display:flex;
    align-items: center;
    margin: 20px;
    border: 1px solid #fff;
    border-radius: 10px;    
    overflow:hidden;

    .timeline{
        width:95%;
        height:92px;    
    }

    .control-timeline{
        width:120px;
        height:92px;   
        display:flex;
        justify-content:space-between;
        align-items: center;
        background-color:#2F2F2F;
        color:#fff;

        .time-area{            
            display:flex;
            justify-content: center;
            align-items: center;
            padding:0 20px;

            span{
                font-size:12px;
                white-space:nowrap;
            }
        }

        .button-area{
            width:50px;            
            height:100%;
            flex-direction: column;
            background:#4F4F4F;

            p{                
                height:50%;
                display:flex;
                align-items: center;
                justify-content: center;
                text-align: center;
                margin:0;
                cursor: pointer;
            }
        }
    }
}

// 아이템의 높이 및 기본 설정
.vis-item {
    top: 0 !important;
    height: 18px !important;
    line-height: 18px !important;

    &.danger {
        color: white;
        background-color: #ff6d87;
        border: none;
    }

    &.warning {
        color: white;
        background-color: #fff500;
        border: none;
    }
}

// 그룹의 높이 고정
.vis-group {
    height: 18px !important; 
}

// 아이템이 그룹의 영역을 초과하지 않도록 제한
.vis-label,
.vis-foreground .vis-item,
.vis-background .vis-item {
    max-height: 18px;
}

// Minor labels 스타일 설정
.vis-time-axis {
    .vis-text {
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 12px;
        font-weight: 700;
        padding: 6px 0;
        height: 20px; 
        line-height: 20px; 
    }
}

// 그룹 레이블 스타일
.vis-labelset {
    .vis-label {
        width: 200px;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 12px;
        background-color: #2F2F2F;
    }
}

.vis-timeline {
    border: none;

     // 날짜 표시 숨기기
    .vis-date {
        display: none;
    }

    .vis-current-time {
        position: absolute;
        top: 27px !important;
        left: 0;
        width: 3px;
        text-align: center;
        z-index: 1;
        background-color: blue; // 현재 시간 CSS
    }    
}
반응형

React에서 Drag & Drop이 필요하여 아래와 같이 만들었습니다.

만들면서 HTML 구조와 React에서 사용 할 수 있게 작업 하였습니다.

 

자세한 사항은 아래 소스 중간중간에 주석을 남겨 두었으니 참고 바랍니다.

function uploadmodal({...props}) {    
    const [isTxtActive, setTxtActive] = useState(false);
    const [isWavActive, setWavActive] = useState(false);
    
    //Drag & Drop
    const handleDragStart = (type) => type == 'txt' ? setTxtActive(true) : setWavActive(true); //Drag를 시작 할 때 이벤트
    const handleDragEnd = (type) => type == 'txt' ? setTxtActive(false) : setWavActive(false); //Drag가 끝났을 때 이벤트
    const handleDragOver = (event,type) => { //파일을 드래그 하고 놨을 때 (이게 없으면 브라우저에서 실행 되거나 다운로드가 되는 증상이 나타남)
        event.preventDefault();
        event.stopPropagation();        
    };    

    const handelDropFile = (e,type) => { //파일을 놨을 때 이벤트
        e.preventDefault();
        e.stopPropagation();

        const newFiles = e.dataTransfer.files[0]; 
        console.log('[handelDropFile]newFiles :' ,newFiles);
    }    

	//label안에 css인 pointer-events:none를 하지 않으면 내용물 안에 영역으로 들어 갔을 때 DragEnter / DragLeave가 한번씩 타면서 초기화가 되는 증상이 나타남.

    return (
        <label 
            className={`${isTxtActive ? 'bg-[#efeef3] border-[#000]' : ''} w-[50vw] max-w-[1000px] min-w-[400px] flex flex-col items-center justify-center border-2 p-[20px] rounded-lg border-dotted mb-[10px] cursor-pointer`} 
            htmlFor="txt-file"
            onDragEnter={() => handleDragStart('txt')}
            onDragOver={handleDragOver}
            onDragLeave={() => handleDragEnd('txt')}                                        
            onDrop={(e) => handelDropFile(e,'txt')}
        >
            <div className="w-[50px] h-[50px] mb-[20px] pointer-events-none" >
                <img src={txtFileIcon} alt={txtFileIcon} />
            </div>
            <div className="mb-[10px] pointer-events-none" >                                    
                <span className="cursor-pointer text-[14px] bg-[#444] text-white px-[10px] py-[5px] rounded-full">파일 선택</span>                                            
            </div>
            <p className="pointer-events-none">또는</p>
            <p className="pointer-events-none">여기로 <span className="font-bold">텍스트(.txt) 파일</span>을 드래그 하세요.</p>                                        
            <input type="file" accept=".txt" id="txt-file" name="txtfile" style={{display:'none'}}></input>
        </label>   
    );
}

export default uploadmodal;
반응형

기존 JavaScript로 된 페이징 버전 함수는 만들어두어서 그대로 사용하면 되는데 React 버전은 따로 만들어 둔게 없어서 아래와 같이 컴포넌트로 만들었습니다.

 

부모 컴포넌트

const [fileList, setFileList] = useState([]) //전체 리스트 데이터
const [onPageChange, setOnPageChange] = useState(1) //현재 페이징 번호
const [onDataPerPage, setOnDataPerPage] = useState(1) //페이지 별 보여지는 리스트 수
const [pageList, setPageList] = useState([]) //현재 페이징에 보여져야 될 리스트

//현재 Page에 맞는 리스트가 보여지게 끔 세팅
const handlePageView = () => {        
    const indexOfLastData = onPageChange * onDataPerPage;
    const indexOfFirstData = indexOfLastData - onDataPerPage;
    const currentData = fileList.slice(indexOfFirstData, indexOfLastData);

    setPageList(currentData);
}

//파일 리스트 최초 검색, 페이징 변경 되면 세팅 시기기
useEffect(() => {
    handlePageView()
}, [fileList,onPageChange])

return (
	<>
    	<Pagination totalData={fileList ? fileList.length : 0} dataPerPage={onDataPerPage} pageCount={5} currentPage={onPageChange} onPageChange={setOnPageChange}/>
    </>
)

 

자식 컴포넌트

mostback : '<<'

left : '<'

right : '>'

mostfront : '>>'

function pagination ({totalData,dataPerPage,pageCount,currentPage,onPageChange}){
    const [pagination, setPagination] = useState({
        firstPage: 1,
        lastPage: 1,
        nextPage: 1,
        prevPage: 1,
    });

    useEffect(() => {
        handlePaging(totalData, dataPerPage, pageCount, currentPage);
    }, [totalData, dataPerPage, pageCount, currentPage]);

    const handlePaging = (totalData, dataPerPage, pageCount, currentPage) => {
        let totalPage = Math.ceil(totalData / dataPerPage); // 총 페이지 수
        let pageGroup = Math.ceil(currentPage / pageCount); // 페이지 그룹

        let lastPage = pageGroup * pageCount; // 화면에 보여질 마지막 페이지 번호
        if (lastPage > totalPage) lastPage = totalPage;

        let firstPage = lastPage - (pageCount - 1); // 화면에 보여질 첫 번째 페이지 번호
        if (firstPage < 1) firstPage = 1;

        let prevPage;
        if (currentPage - 1 < 1) {
            prevPage = 1;
        } else {
            prevPage = currentPage - 1;
        }

        let nextPage;
        if (lastPage <= totalPage) {
            if((currentPage + 1) > totalPage){
                nextPage = lastPage;
            }else{
                nextPage = currentPage + 1;
            }
        } else {
            nextPage = lastPage;
        }

        setPagination({ firstPage, lastPage, nextPage, prevPage });
    };

    const handleClick = (page) => {
        if (onPageChange) {
            onPageChange(page);
        }
    };

    const { firstPage, lastPage, nextPage, prevPage } = pagination;

    return (
        <div className="pagination">
            <span className="first_btn" onClick={() => handleClick(1)}>
                <img src={mostback} alt="First" />
            </span>
            <span className="prev_btn" onClick={() => handleClick(prevPage)}>
                <img src={left} alt="Previous" />
            </span>

            {/* 페이지 번호들 */}
            {[...Array(lastPage - firstPage + 1).keys()].map((i) => (
                <span
                    key={i + firstPage}
                    className={currentPage === i + firstPage ? "current" : ""}
                    onClick={() => handleClick(i + firstPage)}
                >
                    {i + firstPage}
                </span>
            ))}

            <span className="next_btn" onClick={() => handleClick(nextPage)}>
                <img src={right} alt="Next" />
            </span>
            <span
                className="last_btn"
                onClick={() => handleClick(Math.ceil(totalData / dataPerPage))}
            >
                <img src={mostfront} alt="Last" />
            </span>
        </div>
    );
};

export default pagination;
반응형

framer-motion이라는 라이브러리를 이용하여 간단한 애니메이션을 구현 할 수 있다.

아래는 framer-motion을 설치하는 방법의 관련된 URL 입니다.

 

https://www.npmjs.com/package/framer-motion

 

framer-motion

A simple and powerful JavaScript animation library. Latest version: 11.0.5, last published: 7 days ago. Start using framer-motion in your project by running `npm i framer-motion`. There are 3777 other projects in the npm registry using framer-motion.

www.npmjs.com

 

framer-motion을 사용하면 초기시점 , 애니메이션 , 끝났을 때(사라질 때)를 선택하여 애니메이션을 만들 수 있습니다.

아래와 같이 지정하면 아코디언을 만들 수 있습니다.

 

주의 사항

꼭 변동이 되는 부분의 <AnimatePresence>로 감싸주어야지만 사라질 때 (exit) 동작 기능이 됩니다.

'IT > React' 카테고리의 다른 글

[React] Drag & Drop 파일 업로드  (1) 2024.09.26
[React] 페이징 Paging  (0) 2024.09.24
[React] Debounce 정리  (0) 2023.08.23
[React] uncontrolled input이라는 에러 해결  (0) 2023.08.08
[React] 커스텀 Hooks 정리  (0) 2023.08.08
반응형

※ Debounce이란?

- 이벤트를 호출 시 연속적으로 여러번 호출 되는 것이 아닌 일정한 시간 동안 지연 시간을 주어 반복 횟수를 제어하는 것 입니다.

 

import { useRef, useState } from 'react';

export const useDebounce = (value) => {
	const [DebouncedVal, setDebouncedVal] = useState(value);
	const eventBlocker = useRef(null);

	//Timeout을 초기화 시켜줍니다.
	clearTimeout(eventBlocker.current);

	//일정 시간 동안 지연을 시켜줍니다.
	eventBlocker.current = setTimeout(() => {
		setDebouncedVal(value);
	}, 500);

	return DebouncedVal;
};

 

 

 

반응형

※ uncontrolled input이라는 에러 문구

    - 해당 value값에 값이 없을 때에 대한 대비책이 없을 때 뜨는 오류 문구
    - 해결방법 : 값이 없을 때 빈 문자를 대신 적용

 

<input type='text' value={UserName || ''} />

 

반응형

※ 커스텀 훅 정리

- 커스텀 훅 : 리액트의 기본 훅을 활용해서 자주쓰는 기능들을 마치 플러그인처럼 하나의 패키지로 묶어서 재활용하는 형태
- 커스텀 훅 조건 : 
    1. 파일이름이 무조건 use로 시작 해야 됨
    2. 다른 리액트 hook안쪽에서는 호출이 불가능 useEffect
    3. 다른 핸들러 함수 안쪽에서도 호출이 불가능

- useQuery : 문자열로 구성이 고유의 쿼리키라는 것을 이용해서 비동기를 데이터를 가져와서 관리하기 위한 함수
 고유쿼리키(배열), 데이터패칭함수 , 리액트 쿼리 설정값 (객체) 
 비동기 데이터를 패칭해서 리액트 쿼리 설정값에 따라 캐싱처리한 후 리턴
 
- useMutation : 데이터를 가져오는 것 뿐만 아니라 서버의 데이터를 직접 변경 요청 할 수 있는 함수
- useQueryClient : 추가적인 인스턴스의 함수 호출하기 위한 객체

반응형

※ React 18 버전에서의 Suspense

  - 각 페이지에 구성되 있는 컴포넌트들을 동시에 호출하는 것이 아닌 영역별로 렌더링 시점을 동기화 처리
  - 이전 버전까지는 클라이언트 컴포넌트에서만 제한적으로 동작되는 기술이었으나 18버전부터는 ssr 방식의 컴포넌트에서도 활용 가능하도록 개선

활용 예제)
  1. 특정 컴포넌트가 렌더링 완료 될 때까지 다른 컴포넌트의 렌더링을 막고 이전 렌더링 컴포넌트 완료 후 동기적으로 렌더링 시작
  2. 서버로부터 무거운 데이터를 fecthing하는 컴포넌트의 경우 해당 컴포넌트 출력전까지 자동으로 로딩바 출력

 

 

suspense를 활용 하기 위한 조건

- suspense 동기화 시키는 컴포넌트 내부에 promise 객체 생성 상태(pending, fullfilled , rejected)를 추적 할 수 있어야 됨.

 

 

예제)  useGetData.js

import { useState, useEffect } from 'react';
import axios from 'axios';

//promise 객체를 인수로 받아서 해당 promise 상태에 따라 반환되는 값을 직접 리턴해주는 함수를 반환
const checkPromiseStatus = (promise) => {
	let status = 'pending';
	let result;

	//promise의 상태에 따라 현재 상태값과 반환값을 각각 status, result 변수에 담아줌
	const setPromise = promise.then(
		(value) => {
			status = 'success';
			result = value;
		},
		(error) => {
			status = 'error';
			result = error;
		}
	);

	//위에서 저장되는 status값에 따라 fetching된 결과값을 반환하는 함수를 리턴
	return () => {
		switch (status) {
			case 'pending':
				throw setPromise;
			case 'success':
				return result;
			case 'error':
				throw result;
			default:
				throw new Error('Unknow Status');
		}
	};
};

function useGetData(url) {
	const [Data, setData] = useState(null);

	useEffect(() => {
		const getData = async () => {
			//데이터 요청 후 현재 데이터 상태를 확인하는 promise 객체 자체를 비동적으로 받음
			const promise = axios.get('https://jsonplaceholder.typicode.com/posts').then((response) => response.data);

			//해당 promise 객체를 checkPromiseStatus 함수의 인수로 전달해서 직접 동기화 시키는 커스텀함수 호출후 결과값을 반환값을 state에 담아줌
			setData(checkPromiseStatus(promise));
		};

		getData();
	}, [url]);

	//state에 담아진 promise 반환 값을 리턴
	return Data;
}

export default useGetData;

 

Posts.js

import useGetData from './useGetData';

function Posts() {
	const data = useGetData('https://jsonplaceholder.typicode.com/posts');
	console.log(data);
	return (
		<div>
			{data &&
				data.map((post) => {
					return (
						<div key={post.id}>
							<h2>{post.title}</h2>
							<br />
							<p>{post.body}</p>
							<hr />
						</div>
					);
				})}
		</div>
	);
}

export default Posts;

 

App.jsx

import Posts from './Posts';
import { Suspense } from 'react';

function App() {
	return (
		<div className='App'>
			<h1>App</h1>
			<Suspense fallback={<p>Posts loadding...</p>}>
				<h1>Posts</h1>
				<Posts />
			</Suspense>
		</div>
	);
}

export default App;

+ Recent posts