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;
}
},
});