<div class="">
  <section id="widget-b">
    <div class="widget__head">
      <span>Data Sorting</span>
      <div class="actions">
        <button class="button button__refresh" data-action="refresh">Refresh</button>
        <button class="button button__capture" data-action="capture">Capture</button>
      </div>
    </div>
    <div class="widget__body">
      <svg viewBox="0 0 800 120" width="800" xmlns="http://www.w3.org/2000/svg">
        <g id="category">
          <text id="category-label_log" x="730" y="25" width="100" fill="#a1a1a1" class="category-label">log:
            0</text>
          <line x1="400" y1="30" x2="800" y2="30" stroke="#ccc" />
          <text id="category-label_info" x="730" y="55" fill="#1277d4" class="category-label">info:
            0</text>
          <line x1="400" y1="60" x2="800" y2="60" stroke="#ccc" />
          <text id="category-label_warn" x="730" y="85" fill="#fbb13b" class="category-label">warn:
            0</text>
          <line x1="400" y1="90" x2="800" y2="90" stroke="#ccc" />
          <text id="category-label_error" x="730" y="115" fill="#ff154a" class="category-label">error:
            0</text>
        </g>
        <circle id="magnify" cx="425" cy="60" r="60" stroke="#afafaf" fill="#ffffff" />
        <g id="item-group"></g>
      </svg>
      <div class="scroll-area">
        <table class="capture-table">
          <colgroup>
            <col style="width:40px">
            <col style="width:120px">
            <col style="width:50px">
            <col style="width:150px">
            <col style="width:auto">
          </colgroup>
          <thead>
            <th>순서</th>
            <th class="left">제목</th>
            <th>유형</th>
            <th>발생시간</th>
            <th>상세내용</th>
          </thead>
          <tbody id="capture_tbody">
            <tr>
              <td colspan="5">no data.</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </section>
</div>
#widget-b {
    width: 810px;
    border: 1px solid #dee2e6;
}
.widget__head {
    position: relative;
    padding: 4px 8px;
    background-color: #dee2e6;
}
.actions {
    position: absolute;
    right: 8px;
    top: 2px;
    text-align: right;
}

.actions .button {
    padding: 2px 5px;
    background: #E4E8EB;
    border-radius: 4px;
    border: 1px solid #aaa;
    color: #6e6e6e;
    font-size: 14px;
    cursor: pointer;
}

.widget__body {
    padding: 5px;
}
.item.move {
    transition-property: cx, cy, r, box-shadow;
    transition-duration: 1.5s;
    transition-timing-function: cubic-bezier(0.32, 0, 0.67, 0);
}

.item.move.slow {
    transition-property: cx, cy;
    transition-duration: 3s;
    transition-timing-function: cubic-bezier(0.12, 0, 0.39, 0);
}

.category-label {
    width: 50px;
    font-size: 14px;
    text-align: right;
}

.scroll-area {
    max-height: 420px;
    overflow: auto;
}

.capture-table {
    width: 100%;
    table-layout: fixed;
    border-collapse: collapse;
}
.capture-table .left {
    text-align: left;
}
.capture-table thead th {
    padding: 4px 6px;
    background-color: #dee2e6;
    font-size: 14px;
}
.capture-table tbody td {
    padding: 4px 6px;
    font-size: 12px;
    vertical-align: top;
    text-align: center;
}

(function ($) {
    const $el = $('#widget-b')
    const $svg = $el.find('svg')
    const $magnify = $svg.find('#magnify')
    const $itemGroup = $svg.find('#item-group')
    const counter = {
        'log': 0,
        'info': 0,
        'warn': 0,
        'error': 0
    }
    let itemList = [] // [ RandomItem, ]

    // SVG DOM 생성
    function makeSVG(tag, attrs) {
        var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
        for (var k in attrs)
            el.setAttribute(k, attrs[k]);
        return el;
    }
    // 랜덤 데이터 생성
    function RandomItem() {
        // 리스트 중 아이템 한 개를 추출.
        const getRandom = (list) => {
            return list[Math.floor(Math.random() * list.length)]
        }
        this.id = String(Date.now())
        this.name = getRandom(['타이틀A', '타이틀B', '타이틀C', '타이틀D', '타이틀E'])
        this.explain = getRandom([
            '국토와 자원은 국가의 보호를 받으며, 국가는 그 균형있는 개발과 이용을 위하여 필요한 계획을 수립한다. 모든 국민은 학문',
            '모든 국민은 인간다운 생활을 할 권리를 가진다. 나는 헌법을 준수하고 국가를 보위하며 조국의 평화적 통일과 국민의 자유와 복리의 증진 및 민족문화의 창달에 노력하여 대통령으로서의 직책을 성실히 수행할 것을 국민 앞에 엄숙히 선서합니다.',
            '국교는 인정되지 아니하며, 종교와 정치는 분리된다. 지방의회의 조직·권한·의원선거와 지방자치단체의 장의 선임방법 기타 지방자치단체의 조직과 운영에 관한 사항은 법률로 정한다.',
            '중앙선거관리위원회는 대통령이 임명하는 3인, 국회에서 선출하는 3인과 대법원장이 지명하는 3인의 위원으로 구성한다. 위원장은 위원중에서 호선한다.',
            '공무원인 근로자는 법률이 정하는 자에 한하여 단결권·단체교섭권 및 단체행동권을 가진다.'
        ])
        this.type = getRandom(['log', 'info', 'warn', 'error'])
        this.create = Date.now()
    }
    // DOM 템플릿
    const template = {
        item(info) {
            const posY = ($svg.height() * 0.1) + Math.floor($svg.height() * 0.8 * Math.random())
            let color = '#424242'
            switch (info.type) {
                case 'log':
                    color = '#a1a1a1'
                    break
                case 'info':
                    color = '#1277d4'
                    break
                case 'warn':
                    color = '#fbb13b'
                    break
                case 'error':
                    color = '#ff154a'
                    break
            }
            return makeSVG('circle', { id: info.id, cx: 3, cy: posY, r: 2, fill: color, class: 'item', 'data-type': info.type });
        },
        tdItem(info, index) {
            const date = new Date(info.create)
            return `<tr>
                <td>${index + 1}</td>
                <td class="left name">${info.name}</td>
                <td>${info.type}</td>
                <td>${date.getFullYear()}/${date.getMonth()}/${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}.${date.getMilliseconds()}</td>
                <td class="left">${info.explain}</td>
            </tr>`
        }
    }
    //아이템 이동 관련 함수
    const moveItem = (el) => {
        $(el).addClass('move')
        const centerY = $svg.height() / 2
        const powR = Math.pow($magnify.attr('r'), 2)
        const powY = Math.pow(centerY - $(el).attr('cy'), 2)
        const cx = Math.sqrt(powR - powY) // x^2 = r^2 - y^2

        $(el).attr('cx', Math.abs(-cx + Number($magnify.attr('cx'))))

        //돋보기 영역 진입
        setTimeout(() => {
            const type = $(el).data('type')
            const posY = {
                'log': 15,
                'info': 45,
                'warn': 75,
                'error': 105
            }
            const powY = Math.pow(centerY - posY[type], 2)
            const cx = Math.sqrt(powR - powY) // x^2 = r^2 - y^2
            $(el).addClass('slow')
            $(el).attr({
                'cx': Math.abs(cx + Number($magnify.attr('cx'))),
                'cy': posY[type],
                'r': 4
            })

            //돋보기 영역 진입
            setTimeout(() => {
                $(el).attr({
                    'cx': $svg.width() + 4,
                    'r': 2
                })
                $(el).removeClass('slow')

                //돋보기 영역 후
                setTimeout(() => {
                    if ($itemGroup.has(el).length > 0) {
                        counter[type] += 1
                        updateCounter()
                    }
                    //모션 끝난 후
                    setTimeout(() => {
                        el.remove()
                        const targetIndex = itemList.findIndex((item) => item.id === el.id)
                        if (targetIndex >= 0) {
                            itemList.splice(targetIndex, 1)
                        }
                    }, 1000)
                }, 1050)
            }, 3050)
        }, 1550)
    }
    const updateCounter = () => {
        $el.find('#category-label_log', $svg).text('log: ' + counter.log)
        $el.find('#category-label_info', $svg).text('info: ' + counter.info)
        $el.find('#category-label_warn', $svg).text('warn: ' + counter.warn)
        $el.find('#category-label_error', $svg).text('error: ' + counter.error)
    }

    //데이터 자동 생성 함수
    const appendData = () => {
        const interval = Math.floor(Math.random() * 100)
        setTimeout(() => {
            const data = new RandomItem()
            const dataEl = template.item(data)

            itemList.push(data)
            $itemGroup.append(dataEl)
            moveItem(dataEl)
            appendData()
        }, interval)
    }
    appendData()

    document.body.removeAttribute('cloak')

    //이벤트 핸들러
    $el.find('[data-action=refresh]').on('click', function (event) {
        $itemGroup.children().remove()
        itemList = []
        counter.log = 0
        counter.info = 0
        counter.warn = 0
        counter.error = 0
        updateCounter()
        $el.find('#capture_tbody').empty()
    })
    $el.find('[data-action=capture]').on('click', function (event) {
        let captureList = []
        const $tbody = $el.find('#capture_tbody').empty()
        $itemGroup
            .find('.slow')
            .each(function (index, itemEl) {
                const data = itemList.find(itemData => itemData.id === itemEl.id)
                if (data) {
                    captureList.push(data)
                }
            })
        captureList.sort((a, b) => (a.create - b.create))
        captureList.forEach((capture, index) => {
            $tbody.append(template.tdItem(capture, index))
        })
    })
})(jQuery)

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/mustache.js/4.2.0/mustache.min.js