본문 바로가기

개발

d3로 타임라인 만들어 보기

vue에서 d3를 이용해서 타임라인을 만들어보는 코드

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { useTimelineStore } from '@/stores/timelineStore';
import { TimelineChart } from '@/utils/timelineChart';
import type { TimelineItem } from '@/types/timeline';

const store = useTimelineStore();
const chartContainer = ref<HTMLDivElement | null>(null);
const tableWrapper = ref<HTMLDivElement | null>(null);
let chartInstance: TimelineChart | null = null;

let shortCircuitZoom = false
const headers = [
    { title: 'ID', key: 'id', width: '60px' },
    { title: '그룹명', key: 'title' },
    { title: '시각', key: 'time' }
];

// 🎨 [스타일 바인딩] 현재 선택된 Row의 색상을 변경하는 클래스 부여 규칙 설정
const setRowProperties = (data: any) => {
    const item = data.item as TimelineItem;
    return {
        class: store.selectedItem?.id === item.id ? 'highlighted-row' : '',
        'data-id': item.id // 스크롤 타겟 추적용 가상 속성 바인딩
    };
};
// 1️⃣ [테이블 Row 클릭] ➔ 하이라이트 + 줌인 가동
const onRowClick = (_event: Event, { item }: { item: TimelineItem }) => {
    shortCircuitZoom = false // 테이블 클릭 시에는 원래대로 줌인이 작동하도록 플래그 해제
    store.selectItem(item)
}

// 2️⃣ [차트 점 클릭 콜백] ➔ 하이라이트 + 테이블 스크롤 (❌ 줌인 안 됨)
const onChartDotClick = (item: TimelineItem) => {
    // 💡 중요: 차트에서 클릭했으므로 watch문에서 줌인이 실행되지 않도록 플래그를 true로 설정
    shortCircuitZoom = true

    store.selectItem(item)
    if (chartInstance) chartInstance.highlightCircle(item.id)

    // 테이블 자동 스크롤은 유지
    nextTick(() => {
        if (!tableWrapper.value) return
        const targetRow = tableWrapper.value.querySelector(`[data-id="${item.id}"]`) as HTMLElement
        if (targetRow) {
            targetRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
        }
    })
}

// 🔄 Pinia 상태 변경 감시 레이어
watch(() => store.selectedItem, (newItem: TimelineItem | null) => {
    if (!newItem || !chartInstance) return

    if (shortCircuitZoom) {
        // 💡 차트 클릭으로 상태가 변한 것이라면, 하이라이트 원 크기만 변경하고 줌인은 건너뜁니다!
        chartInstance.highlightCircle(newItem.id)
        shortCircuitZoom = false // 다음 인터랙션을 위해 플래그 초기화
    } else {
        // 💡 테이블 클릭으로 상태가 변한 것이라면, 정중앙 줌인과 하이라이트를 모두 수행합니다.
        const targetDate = new Date(newItem.time)
        chartInstance.focusOnTime(targetDate)
        chartInstance.highlightCircle(newItem.id)
    }
})

watch(() => store.rawGridData, (newData) => {
    if (chartInstance) chartInstance.updateData(newData)
}, { deep: true })

onMounted(() => {
    if (chartContainer.value) {
        chartInstance = new TimelineChart(chartContainer.value, store.rawGridData, onChartDotClick)
    }
})

onBeforeUnmount(() => {
    if (chartInstance) chartInstance.destroy()
})
const fitToData = () => {
    chartInstance?.fitToData()
}
</script>

<template>
    <v-container fluid>
        <v-row class="mb-2">
            <v-col cols="12" class="d-flex align-center gap-3">
                <v-btn color="indigo" prepend-icon="mdi-database-refresh" :loading="store.isLoading"
                    @click="store.fetchServerData">
                    서버 데이터 로드
                </v-btn>
                <v-btn variant="tonal" prepend-icon="mdi-refresh" :disabled="store.rawGridData.length === 0"
                    @click="fitToData">
                    크기 리셋
                </v-btn>
            </v-col>
        </v-row>

        <v-row>
            <!-- 좌측 Vuetify 그리드 목록 -->
            <v-col cols="12" md="5">
                <v-card title="데이터 목록" flat border>
                    <div ref="tableWrapper" class="fixed-height-table-wrapper">
                        <v-data-table :headers="headers" :items="store.rawGridData" density="compact" hover
                            hide-default-footer items-per-page="-1" :row-props="setRowProperties"
                            @click:row="onRowClick"></v-data-table>
                    </div>
                </v-card>
            </v-col>

            <!-- 우측 D3 차트 래퍼 -->
            <v-col cols="12" md="7">
                <v-card title="D3 반응형 타임라인" flat border>
                    <!-- 부모 폭인 v-col을 가득 채우도록 % 스타일 적용 -->
                    <div ref="chartContainer" class="chart-container-fixed"></div>
                </v-card>
            </v-col>
        </v-row>
    </v-container>
</template>



<style scoped>
.fixed-height-table-wrapper {
    max-height: 350px;
    overflow-y: auto;
}

.chart-container-fixed {
    position: relative;
    width: 100%;
    /* 📐 유연한 반응형 레이아웃의 핵심 */
    overflow: hidden;
    background-color: #fcfdfe;
    height: 350px;
}

:deep(.highlighted-row) {
    background-color: #E8F5E9 !important;
    font-weight: bold;
    color: #2E7D32;
}
</style>

 

export interface TimelineItem {
    id: number;
    title: string;
    time: string;
    description: string;
}

export interface ChartDataItem extends TimelineItem {
    date: Date;
}

 

import * as d3 from 'd3';
import type { TimelineItem, ChartDataItem } from '@/types/timeline';

export class TimelineChart {
    private container: HTMLDivElement;
    private rawData: TimelineItem[] = [];
    private data: ChartDataItem[] = [];
    private uniqueTitles: string[] = [];

    private margin = { top: 40, right: 40, bottom: 40, left: 100 };
    private width: number = 0;
    private height: number = 0;

    // D3 컴포넌트 변수
    private baseSvg!: d3.Selection<SVGSVGElement, unknown, null, undefined>;
    private svg!: d3.Selection<SVGGElement, unknown, null, undefined>;
    private xAxisGroup!: d3.Selection<SVGGElement, unknown, null, undefined>;
    private yAxisGroup!: d3.Selection<SVGGElement, unknown, null, undefined>;
    private mainGroup!: d3.Selection<SVGGElement, unknown, null, undefined>;
    private dots!: d3.Selection<SVGGElement, ChartDataItem, SVGGElement, unknown>;
    private clipRect!: d3.Selection<SVGRectElement, unknown, null, undefined>;

    private xScale!: d3.ScaleTime<number, number>;
    private yScale!: d3.ScalePoint<string>;
    private colorScale!: d3.ScaleOrdinal<string, string>;

    private zoomBehavior!: d3.ZoomBehavior<any, unknown>;
    private tooltip!: d3.Selection<HTMLDivElement, unknown, null, undefined>;
    private onDotClickCallback?: (item: TimelineItem) => void;

    // 📐 리사이즈 감지 관찰자 추가
    private resizeObserver!: ResizeObserver;

    constructor(container: HTMLDivElement, data: TimelineItem[], onDotClick?: (item: TimelineItem) => void) {
        this.container = container;
        this.rawData = data;
        this.onDotClickCallback = onDotClick;
        this.init();
    }

    private init(): void {
        this.data = this.rawData.map(d => ({ ...d, date: new Date(d.time) }));
        this.uniqueTitles = [...new Set(this.data.map(d => d.title))].sort();

        // 초기 크기 계산
        this.width = this.container.clientWidth - this.margin.left - this.margin.right;
        this.height = Math.max(this.uniqueTitles.length * 70, 270);

        // 툴팁 레이어 바인딩
        this.tooltip = d3.select(this.container).select('.d3-custom-tooltip');
        if (this.tooltip.empty()) {
            this.tooltip = d3.select(this.container)
                .append('div')
                .attr('class', 'd3-custom-tooltip')
                .style('position', 'absolute')
                .style('visibility', 'hidden')
                .style('background-color', 'rgba(33, 33, 33, 0.95)')
                .style('color', '#fff')
                .style('padding', '10px 14px')
                .style('border-radius', '6px')
                .style('font-size', '12px')
                .style('pointer-events', 'none')
                .style('z-index', '100')
                .style('white-space', 'nowrap');
        }

        this.baseSvg = d3.select(this.container)
            .append('svg')
            .attr('width', this.width + this.margin.left + this.margin.right)
            .attr('height', this.height + this.margin.top + this.margin.bottom);

        this.svg = this.baseSvg.append('g')
            .attr('transform', `translate(${this.margin.left}, ${this.margin.top})`);

        this.xScale = d3.scaleTime().domain([new Date(), new Date()]).range([0, this.width]);
        this.yScale = d3.scalePoint().domain(this.uniqueTitles).range([30, this.height - 30]);
        this.colorScale = d3.scaleOrdinal<string, string>(d3.schemeCategory10).domain(this.uniqueTitles);

        // 줌을 가두는 ClipPath 정의 및 캐싱
        this.clipRect = this.baseSvg.append('defs').append('clipPath')
            .attr('id', 'clip-ts-hd')
            .append('rect')
            .attr('width', this.width)
            .attr('height', this.height);

        this.xAxisGroup = this.svg.append('g').attr('transform', `translate(0, ${this.height})`);
        this.yAxisGroup = this.svg.append('g');

        this.mainGroup = this.svg.append('g').attr('clip-path', 'url(#clip-ts-hd)');

        this.setupZoom();
        this.updateData(this.rawData);

        // 📐 [리사이즈 관찰자 등록] 부모 컨테이너의 크기 변화를 실시간으로 감지
        this.resizeObserver = new ResizeObserver(() => {
            this.resize();
        });
        this.resizeObserver.observe(this.container);
    }

    /**
     * 📐 화면 크기가 늘어나거나 줄어들 때 호출되는 동적 갱신 핵심 메서드
     */
    public resize(): void {
        if (!this.container || this.container.clientWidth === 0) return;

        // 1. 변화된 컨테이너 크기 재계산
        this.width = this.container.clientWidth - this.margin.left - this.margin.right;

        // 2. SVG 자체 뷰 박스 / 너비 속성 갱신
        this.baseSvg.attr('width', this.width + this.margin.left + this.margin.right);
        this.clipRect.attr('width', this.width); // 클리핑 마스크 영역도 함께 리사이즈

        // 3. X축 스케일 출력 범위(Range) 다시 매핑
        this.xScale.range([0, this.width]);

        // 4. 가이드라인 선 너비 재조정
        this.mainGroup.selectAll<SVGLineElement, string>('.line-row')
            .attr('x2', this.width);

        // 5. 현재 줌(Zoom) 상태의 변환 행렬 정보를 가져옴
        const currentTransform = d3.zoomTransform(this.baseSvg.node() as any);
        const currentXScale = currentTransform.rescaleX(this.xScale);

        // 6. 리사이즈된 좌표에 맞춰 화면 다시 그리기
        this.renderElements(currentXScale);
    }

    private setupZoom(): void {
        this.zoomBehavior = d3.zoom<any, unknown>()
            .scaleExtent([0.1, 100])
            .on('zoom', (event) => {
                const zoomedXScale = event.transform.rescaleX(this.xScale);
                this.renderElements(zoomedXScale);
            });
        this.baseSvg.call(this.zoomBehavior);
    }

    public updateData(newData: TimelineItem[]): void {
        this.rawData = newData;
        this.data = this.rawData.map(d => ({ ...d, date: new Date(d.time) }));
        this.uniqueTitles = [...new Set(this.data.map(d => d.title))].sort();

        this.yScale.domain(this.uniqueTitles);
        this.yAxisGroup.call(d3.axisLeft(this.yScale)).style('font-size', '13px').style('font-weight', 'bold');

        this.mainGroup.selectAll('.line-row').remove();
        this.mainGroup.selectAll('.line-row')
            .data(this.uniqueTitles)
            .enter()
            .append('line')
            .attr('class', 'line-row')
            .attr('x1', 0)
            .attr('x2', this.width)
            .attr('y1', d => this.yScale(d) || 0)
            .attr('y2', d => this.yScale(d) || 0)
            .attr('stroke', '#eef0f3')
            .attr('stroke-dasharray', '4 4');

        this.mainGroup.selectAll('.dot-group').remove();
        this.dots = this.mainGroup.selectAll<SVGGElement, ChartDataItem>('.dot-group')
            .data(this.data)
            .enter()
            .append('g')
            .attr('class', 'dot-group');

        this.dots.append('circle')
            .attr('r', 8)
            .attr('fill', d => this.colorScale(d.title))
            .attr('stroke', '#fff')
            .attr('stroke-width', 2)
            .style('cursor', 'pointer')
            .on('mouseover', (event, d) => {
                d3.select(event.currentTarget).transition().duration(100).attr('r', 11);
                this.tooltip.style('visibility', 'visible').style('opacity', '1');
                this.tooltip.html(`
          <div style="font-weight: bold; margin-bottom: 2px; color: #64B5F6;">📌 ${d.title}</div>
          <div style="color: #B0BEC5; font-size: 11px; margin-bottom: 4px;">🕒 ${d.time}</div>
          <div style="border-top: 1px solid #555; padding-top: 4px; font-size: 12px;">${d.description}</div>
        `);
            })
            .on('mousemove', (event) => {
                const [mouseX, mouseY] = d3.pointer(event, this.container);
                const containerWidth = this.container.clientWidth;
                const tooltipNode = this.tooltip.node();
                const tooltipWidth = tooltipNode ? tooltipNode.getBoundingClientRect().width : 200;

                let targetX = mouseX + 15;
                if (mouseX + tooltipWidth + 30 > containerWidth) {
                    targetX = mouseX - tooltipWidth - 15;
                }

                this.tooltip.style('left', `${targetX}px`).style('top', `${mouseY - 25}px`);
            })
            .on('mouseleave', (event) => {
                d3.select(event.currentTarget).transition().duration(100).attr('r', 8);
                this.tooltip.style('visibility', 'hidden').style('opacity', '0');
            })
            .on('click', (_event, d) => {
                if (this.onDotClickCallback) this.onDotClickCallback(d);
            });

        this.fitToData();
    }

    public fitToData(): void {
        if (this.data.length === 0) return;
        let timeExtent = d3.extent(this.data, d => d.date) as [Date, Date];
        if (timeExtent[0].getTime() === timeExtent[1].getTime()) {
            timeExtent = [d3.timeDay.offset(timeExtent[0], -1), d3.timeDay.offset(timeExtent[1], 1)];
        } else {
            const diff = timeExtent[1].getTime() - timeExtent[0].getTime();
            timeExtent = [new Date(timeExtent[0].getTime() - diff * 0.05), new Date(timeExtent[1].getTime() + diff * 0.05)];
        }
        this.xScale.domain(timeExtent);
        this.baseSvg.transition().duration(750).call(this.zoomBehavior.transform, d3.zoomIdentity);
    }

    private renderElements(currentXScale: d3.ScaleTime<number, number>): void {
        this.xAxisGroup.call(d3.axisBottom(currentXScale).ticks(5).tickFormat(d3.timeFormat('%m-%d %H:%M') as any));
        this.dots.attr('transform', d => `translate(${currentXScale(d.date)}, ${this.yScale(d.title) || 0})`);
    }

    public highlightCircle(targetId: number): void {
        this.dots.selectAll('circle')
            .transition().duration(300)
            .attr('r', (d: any) => d.id === targetId ? 14 : 8)
            .attr('stroke', (d: any) => d.id === targetId ? '#00E676' : '#fff')
            .attr('stroke-width', (d: any) => d.id === targetId ? 3 : 2);
    }

    public destroy(): void {
        // 📐 관찰자 해제 처리 (메모리 누수 차단)
        if (this.resizeObserver) {
            this.resizeObserver.disconnect();
        }
        this.baseSvg.on('.zoom', null);
        d3.select(this.container).selectAll('*').remove();
    }
    public focusOnTime(targetDate: Date): void {
        // 1. 줌인 시 보여주고 싶은 전후 시간 범위 설정 (예: 총 12시간 폭으로 확대)
        const viewHalfWindowMs = 6 * 60 * 60 * 1000; // 6시간
        const newDomain: [Date, Date] = [
            new Date(targetDate.getTime() - viewHalfWindowMs),
            new Date(targetDate.getTime() + viewHalfWindowMs)
        ];

        // 2. 기본 스케일 도메인 대비 목표 도메인의 배율(k)을 연산
        const baseDuration = this.xScale.domain()[1].getTime() - this.xScale.domain()[0].getTime();
        const targetDuration = newDomain[1].getTime() - newDomain[0].getTime();
        const k = baseDuration / targetDuration;

        // 3. 💡 핵심 알고리즘: 타겟 점이 차트 가로 영역의 딱 절반(this.width / 2)에 안착하도록 평행이동 값(tx) 계산
        // 식: tx = (화면 중심 오프셋) - (기본 좌표계 상의 타겟 위치 * 배율)
        const tx = (this.width / 2) - (this.xScale(targetDate) * k);

        // 4. 새로운 D3 줌 변환 행렬 객체 생성
        const transform = d3.zoomIdentity.translate(tx, 0).scale(k);

        // 5. 부드러운 애니메이션 효과와 함께 줌 동작 적용
        this.baseSvg.transition()
            .duration(750)
            .ease(d3.easeCubicOut)
            .call(this.zoomBehavior.transform, transform);
    }
}

 

import { defineStore } from 'pinia';
import type { TimelineItem } from '@/types/timeline';

interface TimelineState {
    rawGridData: TimelineItem[];
    selectedItem: TimelineItem | null;
    isLoading: boolean;
}

export const useTimelineStore = defineStore('timeline', {
    state: (): TimelineState => ({
        rawGridData: [],
        selectedItem: null,
        isLoading: false
    }),

    actions: {
        async fetchServerData(): Promise<void> {
            this.isLoading = true;
            await new Promise(resolve => setTimeout(resolve, 800));

            // 스크롤 테스트를 위해 12개의 대량 샘플 데이터 생성
            this.rawGridData = Array.from({ length: 30 }, (_, i) => ({
                id: i + 1,
                title: `서버 ${String.fromCharCode(65 + (i % 3))}`, // A, B, C 루프
                time: `2026-10-${10 + Math.floor(i / 2)} ${String(9 + (i % 5) * 2).padStart(2, '0')}:00`,
                description: `시스템 프로세스 #${i + 1} 상세 리포트 내용입니다.`
            }))
            this.rawGridData.sort((a: any, b: any) => a.time - b.time);
            this.isLoading = false;
        },

        selectItem(item: TimelineItem | null): void {
            this.selectedItem = item;
        }
    },
});