반응형

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;
반응형

div 2개만 이용하고 Percent를 변경하여 사용하고 싶어서 아래와 같이 만들었습니다.

 

 

HTML (React)

uploadPercent는 useState로 변경 하는 로직을 사용 하였습니다.

<div className="progress-bg">
    <div className="progress-bar" style={{width:`${uploadPercent}%`}}></div>
</div>

 

CSS (SASS)

/* 프로그레스바 배경 */
.progress-bg {
    width: 100%;
    height: 10px;
    background-color: #aeb5c4; /* 배경색 */
    border-radius: 15px;
    overflow: hidden;

    /* 진행 부분 */
    .progress-bar {
        height: 100%;
        width: 0%; /* 진행 상태: 0% ~ 100% */
        background-color: #193263; /* 진행바 색상 */
        opacity: 0.9;
        border-radius: 15px 0 0 15px;
        transition: width 0.3s ease;
    }
}

 

반응형

기존 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;
반응형

테일윈드를 사용하면 절대로 쓸 일이 없는 구문이긴 하지만 테일윈드나 다른 프레임워크를 설정하기 어려워서 하나 만들어 보았습니다.

 

Padding

//숫자를 200 -> 1000 하면 1000까지 가능
@for $i from 0 through 200 {
    .pd-#{$i} {
        padding: #{$i}px !important;
    }

    .pt-#{$i} {
        padding-top: #{$i}px !important;
    }

    .pr-#{$i} {
        padding-right: #{$i}px !important;
    }

    .pb-#{$i} {
        padding-bottom: #{$i}px !important;
    }

    .pl-#{$i} {
        padding-left: #{$i}px !important;
    }
}

 

Margin

//숫자를 200 -> 1000 하면 1000까지 가능 (조절가능)
@for $i from 0 through 200 {
    .md-#{$i} {
        margin: #{$i}px !important;
    }

    .mt-#{$i} {
        margin-top: #{$i}px !important;
    }

    .mr-#{$i} {
        margin-right: #{$i}px !important;
    }

    .mb-#{$i} {
        margin-bottom: #{$i}px !important;
    }

    .ml-#{$i} {
        margin-left: #{$i}px !important;
    }
}

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

[SASS] SASS 설치 + 실행 법 정리  (0) 2023.05.22
반응형

Select와 Option으로 사용 시 스타일링의 어려움이 있기 때문에 아래와 같이 ul 과 li로 구성 하였습니다.

 

HTML

<div class="dropdown">
    <div class="dropdown-selected">Select an option</div>
    <ul class="dropdown-options">
        <li data-value="1">Option 1</li>
        <li data-value="2">Option 2</li>
        <li data-value="3">Option 3</li>
        <li data-value="4">Option 4</li>
    </ul>
</div>

 

CSS (SASS)

.dropdown {
    position: relative;
    width: 250px;

    /* 선택된 항목 표시 */
    .dropdown-selected {
        padding: 5px;
        border: 1px solid #ccc;
        border-radius: 4px;
        background-color: #f9f9f9;
        cursor: pointer;
        font-size: 16px;
        color: #333;
    }

    /* 화살표 표시 */
    .dropdown-selected::after {
        content: "▼";
        position: absolute;
        top: 50%;
        right: 15px;
        transform: translateY(-50%);
        font-size: 12px;
        color: #333;
    }

    /* 옵션 목록 */
    .dropdown-options {
        display: block;
        list-style: none;
        margin: 0;
        padding: 0;
        position: absolute;
        width: 100%;
        border: 1px solid #ccc;
        background-color: #fff;
        z-index: 100;
        border-radius: 4px;
        max-height: 150px;
        overflow-y: auto;

        &.show {
            display: show;
        }

        /* 옵션 항목 */
        li {
            padding: 10px;
            cursor: pointer;

            /* 호버 및 선택된 항목 스타일 */
            &:hover{
                background-color: #007bff;
                color: #fff;
            }

            &.selected{
                background-color: #007bff;
                color: #fff;
            }
        }
    }
}

 

 

아래는 참고용으로 Vue로 Select Option을 제대로 이벤트 동작하게끔 하기 위해 정리합니다.

Vue

<div class="dropdown" ref="dropdown">
    <div class="dropdown-selected" @click="toggleDropdown">Select an option</div>
    <ul class="dropdown-options" v-if="isOpen">
        <li data-value="1" @click="selectOption(1)">Option 1</li>
        <li data-value="2" @click="selectOption(2)">Option 2</li>
        <li data-value="3" @click="selectOption(3)">Option 3</li>
        <li data-value="4" @click="selectOption(4)">Option 4</li>
    </ul>
</div>

export default{
	data() {
        return {
            isOpen:false
        }
    },
    methods: {
    	toggleDropdown() {
            this.isOpen = !this.isOpen;
        },
        selectOption(option) {
            this.selectedText = option.text;
            this.isOpen = false;
        },
        handleClickOutside(event) {
            // dropdown 외부 클릭 시 드롭다운 닫기
            if (!this.$refs.dropdown.contains(event.target)) {
                this.isOpen = false;
            }
        }
    },
    mounted() {
        document.addEventListener('click', this.handleClickOutside);
    },
    beforeDestroy() {
        document.removeEventListener('click', this.handleClickOutside);
    },    
}

 

반응형
<div class="loading-ispinner">
    <div class="ispinner ispinner-large">
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
        <div class="ispinner-blade"></div>
    </div>
</div>

 

/* Progress Bar (iSpinner) - 로딩 바 */
.loading{position: fixed;width: 100%;height: 100%;z-index: 12000;top: 0;left:0;background: rgba(0,0,0,.7);}
.ispinner {width: 52px;height: 52px;position: absolute;top: 50%;left: 50%;margin: -26px 0 0 -26px}
.ispinner .ispinner-blade {position: absolute;top: 37%;left: 44.5%;width: 10%;height: 25%;background-color: #fff;border-radius: 50%/20%;
    -webkit-animation: iSpinnerBlade 1s linear infinite;
            animation: iSpinnerBlade 1s linear infinite;
    will-change: opacity; 
}
.ispinner .ispinner-blade:nth-child(1) {
  -webkit-transform: rotate(30deg) translate(0, -150%);
          transform: rotate(30deg) translate(0, -150%);
  -webkit-animation-delay: -1.6666666667s;
          animation-delay: -1.6666666667s; }
.ispinner .ispinner-blade:nth-child(2) {
  -webkit-transform: rotate(60deg) translate(0, -150%);
          transform: rotate(60deg) translate(0, -150%);
  -webkit-animation-delay: -1.5833333333s;
          animation-delay: -1.5833333333s; }
.ispinner .ispinner-blade:nth-child(3) {
  -webkit-transform: rotate(90deg) translate(0, -150%);
          transform: rotate(90deg) translate(0, -150%);
  -webkit-animation-delay: -1.5s;
          animation-delay: -1.5s; }
.ispinner .ispinner-blade:nth-child(4) {
  -webkit-transform: rotate(120deg) translate(0, -150%);
          transform: rotate(120deg) translate(0, -150%);
  -webkit-animation-delay: -1.4166666667s;
          animation-delay: -1.4166666667s; }
.ispinner .ispinner-blade:nth-child(5) {
  -webkit-transform: rotate(150deg) translate(0, -150%);
          transform: rotate(150deg) translate(0, -150%);
  -webkit-animation-delay: -1.3333333333s;
          animation-delay: -1.3333333333s; }
.ispinner .ispinner-blade:nth-child(6) {
  -webkit-transform: rotate(180deg) translate(0, -150%);
          transform: rotate(180deg) translate(0, -150%);
  -webkit-animation-delay: -1.25s;
          animation-delay: -1.25s; }
.ispinner .ispinner-blade:nth-child(7) {
  -webkit-transform: rotate(210deg) translate(0, -150%);
          transform: rotate(210deg) translate(0, -150%);
  -webkit-animation-delay: -1.1666666667s;
          animation-delay: -1.1666666667s; }
.ispinner .ispinner-blade:nth-child(8) {
  -webkit-transform: rotate(240deg) translate(0, -150%);
          transform: rotate(240deg) translate(0, -150%);
  -webkit-animation-delay: -1.0833333333s;
          animation-delay: -1.0833333333s; }
.ispinner .ispinner-blade:nth-child(9) {
  -webkit-transform: rotate(270deg) translate(0, -150%);
          transform: rotate(270deg) translate(0, -150%);
  -webkit-animation-delay: -1s;
          animation-delay: -1s; }
.ispinner .ispinner-blade:nth-child(10) {
  -webkit-transform: rotate(300deg) translate(0, -150%);
          transform: rotate(300deg) translate(0, -150%);
  -webkit-animation-delay: -0.9166666667s;
          animation-delay: -0.9166666667s; }
.ispinner .ispinner-blade:nth-child(11) {
  -webkit-transform: rotate(330deg) translate(0, -150%);
          transform: rotate(330deg) translate(0, -150%);
  -webkit-animation-delay: -0.8333333333s;
          animation-delay: -0.8333333333s; }
.ispinner .ispinner-blade:nth-child(12) {
  -webkit-transform: rotate(360deg) translate(0, -150%);
          transform: rotate(360deg) translate(0, -150%);
  -webkit-animation-delay: -0.75s;
          animation-delay: -0.75s; }
.ispinner.ispinner-large {width: 50px;height: 50px; }
.ispinner.ispinner-large .ispinner-blade {
   width: 5.5714285714%;
   height: 25.7142857143%;
border-radius: 50%/16.67%; 
}
@-webkit-keyframes iSpinnerBlade {
  0% {opacity: 0.9;}
  50% {opacity: 0.25;}
  100% {opacity: 0.25;} 
}
@keyframes iSpinnerBlade {
  0% {opacity: 0.9;}
  50% {opacity: 0.25;}
  100% {opacity: 0.25;} 
}
/* Progress Bar - iSpinner */

 

반응형

replace를 사용하여 영문 , 숫자 , 특수문자만 사용할 수 있게 설정한 Javascript 유효성 입니다.

value.replace(/[^a-zA-Z0-9!@#$%^&*(),.?":{}|<>]/g, '')

 

 

+ Recent posts