Skip to content

SVG Charts

@jennifersoft/apm-components의 SVG 차트 모듈 문서입니다. 이 문서는 @jennifersoft/apm-components@1.4.1 기준으로 정리했습니다.

chart 모듈은 레이아웃 관측 / 기하 계산 / SVG 렌더링을 분리한 Cartesian 차트 조합 시스템입니다. d3-scale·d3-shape로 좌표만 계산하고, 실제 그리기는 상태 없는 primitive들이 담당합니다.

아키텍처

SvgCartesianChart (조합 · 호버/툴팁 상태 소유)
├── SvgChartFrame          레이아웃 관측 → layout-change(ChartLayout)
│   ├── SvgChartGrid       배경 격자
│   ├── SvgChartAxis       X/Y 축 · 눈금 · 레이블
│   ├── SvgChartPathSeries line / area / stackarea 의 path
│   ├── SvgChartRectSeries column / stackcolumn 의 rect
│   └── SvgChartCrosshair  호버 십자선
├── SvgChartTooltip        호버 지점 값 표시
├── SvgChartState          empty / error 상태
└── ChartLegend            범례 (토글 가능)

기하 계산은 Vue와 무관한 순수 함수 createCartesianChartGeometry()가 담당합니다. primitive들은 DTO를 모르고 계산된 화면 좌표만 받으므로, 다른 차트를 조립할 때 그대로 재사용할 수 있습니다.

렌더링 흐름

  1. SvgChartFrame이 컨테이너 폭을 useResizeObserver로 관측해 ChartLayout을 계산하고 layout-change로 올려보냅니다.
  2. SvgCartesianChart가 그 layout과 props(data, series, type)로 createCartesianChartGeometry()를 호출합니다.
  3. 반환된 xTicks / yTicks / pathSeries / rectSeries / xPositions를 각 primitive에 넘깁니다.
  4. 마우스 이동 시 xPositions에서 가장 가까운 인덱스를 찾아 crosshair와 tooltip을 갱신합니다.

NOTE

width를 주지 않으면 컨테이너 폭을 관찰합니다. 폭이 0인 컨테이너(예: display: none) 안에서는 CHART_MINIMUM_SVG_DIMENSION(1px)로 떨어지므로, 탭 전환 등으로 뒤늦게 보이는 영역에서는 표시 시점에 리사이즈가 한 번 더 발생해야 정상 폭이 잡힙니다.

인터랙티브 데모

gap 패턴은 valuesnull을 넣어 결측 처리를 확인할 수 있습니다. line·area는 선이 끊기고, column은 해당 막대가 그려지지 않습니다. signed 패턴을 stackarea / stackcolumn과 함께 고르면 양수·음수가 0 기준으로 따로 누적되는 것을 볼 수 있습니다.

ChartLegend 토글

범례는 숨김 상태를 소유하지 않습니다. 아래 데모는 toggle 이벤트를 받아 호출자가 hidden을 다시 내려주는 구조입니다.

Primitive 직접 조립 (스파크라인)

SvgChartFrame으로 레이아웃만 받고 createCartesianChartGeometry()로 path를 계산해 SvgChartPathSeries에 넘기는 최소 구성입니다. 축·격자·툴팁이 없습니다.

헬퍼 함수 동작 확인

normalizeChartDomain()이 domain 붕괴를 어떻게 방어하는지 직접 확인할 수 있습니다. 값이 모두 같으면 [min - p, max + p]로 벌어집니다.

공개 타입

typescript
/** 그릴 series 하나의 식별·표시 정보 */
interface CartesianChartSeries {
    id: string;
    label: string;
    color: string;
}

/** x 한 시점과 series별 값 */
interface CartesianChartDataPoint {
    x: number;
    label: string;
    values: Readonly<Record<string, number | null | undefined>>;
}

type CartesianChartType = 'line' | 'column' | 'area' | 'stackarea' | 'stackcolumn';

/** SvgChartFrame이 계산해 내려주는 렌더링 영역 */
interface ChartLayout {
    width: number;
    height: number;
    plotLeft: number;
    plotTop: number;
    plotWidth: number;
    plotHeight: number;
    plotRight: number;
    plotBottom: number;
}

interface ChartPadding {
    top: number;
    right: number;
    bottom: number;
    left: number;
}

interface ChartAxisTick {
    value: number;
    position: number; // 화면 좌표
    label: string;
}

interface ChartLegendItem {
    id: string;
    label: string;
    color: string;
    hidden?: boolean;
}

interface ChartTooltipItem {
    id: string;
    label: string;
    value: string;
    color?: string;
}

interface ChartRect {
    x: number;
    y: number;
    width: number;
    height: number;
}

type ChartStateKind = 'empty' | 'error';

/** 문구와 retry 의도는 호출자가 소유한다 */
interface ChartStateView {
    kind: ChartStateKind;
    title: string;
    description?: string;
    retryLabel?: string;
}

type ChartValueFormatter = (value: number) => string;

valuesnull / undefined / 비유한값이면 해당 지점은 결측으로 처리되어 line·area는 선이 끊기고, column은 막대를 그리지 않습니다.

SvgCartesianChart

모든 차트 타입을 그리는 조합 컴포넌트입니다. 타입별 래퍼를 쓰지 않고 직접 type을 넘길 때 사용합니다.

Props

Prop타입기본값설명
ariaLabelstring— (필수)차트 전체를 설명하는 접근성 이름
typeCartesianChartType— (필수)차트 종류
datareadonly CartesianChartDataPoint[]— (필수)x축 순서대로 정렬된 데이터
seriesreadonly CartesianChartSeries[]— (필수)그릴 series 목록
widthnumberundefined미지정 시 컨테이너 폭 관측
heightnumber160CHART_DEFAULT_HEIGHT
paddingChartPadding{top:8,right:8,bottom:24,left:28}plot 바깥 여백
xTickCountnumber3X축 눈금 개수
yTickCountnumber3Y축 눈금 개수
xDomainreadonly [number, number]undefined미지정 시 데이터에서 유도
yDomainreadonly [number, number]undefined미지정 시 데이터에서 유도
valueFormatterChartValueFormatterundefinedY축 눈금·툴팁 값 포맷
showLegendbooleantrue범례 표시 여부
stateChartStateViewundefined지정 시 데이터와 무관하게 상태 화면 강제
emptyStateOmit<ChartStateView, 'kind'>undefined데이터가 없을 때 보여줄 문구

이벤트

typescript
emit('retry')  // 상태 화면의 retryLabel 버튼 클릭 시

상태 화면 우선순위

  1. state가 있으면 항상 그 상태를 표시합니다 (에러 표시에 사용).
  2. state가 없고 데이터가 비었으며 emptyState가 있으면 kind: 'empty'로 표시합니다.
  3. 둘 다 없으면 빈 plot 영역만 그립니다.

여기서 "데이터가 비었다"는 data.length === 0뿐 아니라 series가 비었거나, 모든 값이 숫자가 아닌 경우까지 포함합니다.

기본 사용법

vue
<script setup lang="ts">
import { SvgCartesianChart } from '@jennifersoft/apm-components';
import type {
    CartesianChartDataPoint,
    CartesianChartSeries,
} from '@jennifersoft/apm-components';

const series: CartesianChartSeries[] = [
    { id: 'tps', label: 'TPS', color: 'var(--blue-500)' },
    { id: 'error', label: 'Error', color: 'var(--red-500)' },
];

const data: CartesianChartDataPoint[] = [
    { x: 1, label: '10:00', values: { tps: 120, error: 2 } },
    { x: 2, label: '10:01', values: { tps: 138, error: 0 } },
    { x: 3, label: '10:02', values: { tps: 96, error: 7 } },
    { x: 4, label: '10:03', values: { tps: 151, error: 1 } },
];
</script>

<template>
    <SvgCartesianChart
        aria-label="분당 처리 건수"
        type="line"
        :data="data"
        :series="series"
        :height="200"
        :value-formatter="(v) => v.toLocaleString()"
        :empty-state="{ title: '표시할 데이터가 없습니다.' }"
    />
</template>

에러 상태와 재시도

vue
<template>
    <SvgCartesianChart
        aria-label="분당 처리 건수"
        type="line"
        :data="data"
        :series="series"
        :state="
            loadFailed
                ? {
                      kind: 'error',
                      title: '데이터를 불러오지 못했습니다.',
                      description: '네트워크 상태를 확인해 주세요.',
                      retryLabel: '다시 시도',
                  }
                : undefined
        "
        @retry="reload"
    />
</template>

타입별 래퍼 컴포넌트

아래 5개는 SvgCartesianCharttype만 고정한 래퍼입니다. props는 모두 SvgChartRendererProps(= SvgCartesianChartProps에서 type 제외)로 동일하고, retry 이벤트도 그대로 전달됩니다.

컴포넌트고정 type용도
SvgLineChartline추이 비교. 0을 domain에 포함하지 않음
SvgAreaChartarea단일 계열 볼륨 강조. baseline은 plot 하단
SvgColumnChartcolumn시점별 비교. series 개수만큼 그룹 내 분할
SvgStackAreaChartstackarea구성비 누적 추이
SvgStackColumnChartstackcolumn구성비 누적 비교
vue
<script setup lang="ts">
import { SvgStackColumnChart } from '@jennifersoft/apm-components';
</script>

<template>
    <SvgStackColumnChart
        aria-label="서비스별 트랜잭션 구성"
        :data="data"
        :series="series"
    />
</template>

IMPORTANT

line을 제외한 모든 타입은 y domain에 0을 자동으로 포함합니다. 값이 0 근처에서만 변동하는 데이터를 확대해 보려면 yDomain을 직접 지정하세요.

누적 차트의 음수 처리

stackarea / stackcolumn은 양수와 음수를 별도 스택으로 쌓습니다. 양수는 0 위로, 음수는 0 아래로 누적되므로 부호가 섞여도 서로 상쇄되지 않습니다. 결측값은 누적 합을 유지한 채 defined: false로 표시되어 해당 구간만 끊깁니다.

Primitive 컴포넌트

SvgCartesianChart로 표현이 안 되는 차트를 직접 조립할 때 사용합니다. 모두 상태가 없고, 계산된 좌표만 받아 SVG 요소를 그립니다.

SvgChartFrame

레이아웃을 관측해 ChartLayout을 계산하는 컨테이너입니다. 기본 슬롯에 primitive들을 넣습니다.

Prop타입기본값설명
ariaLabelstringundefined없으면 SVG를 보조 기술에서 숨김
widthnumberundefined미지정 시 컨테이너 폭 관측
heightnumber160
paddingChartPaddingCHART_DEFAULT_PADDING
typescript
emit('layout-change', layout: ChartLayout)  // 마운트 직후 즉시 1회 + 리사이즈마다

SvgChartAxis

Prop타입기본값
layoutChartLayout— (필수)
xTicksreadonly ChartAxisTick[][]
yTicksreadonly ChartAxisTick[][]
colorstringvar(--gray-600)
edgeColorstringvar(--gray-500)
fontSizenumber11
xLabelOffsetnumber6
yLabelOffsetnumber6
showXAxisbooleantrue
showYAxisbooleantrue

SvgChartGrid

Prop타입기본값
layoutChartLayout— (필수)
horizontalPositionsreadonly number[][]
verticalPositionsreadonly number[][]
strokestringvar(--gray-200)
strokeWidthnumber1

격자 위치는 눈금과 자동으로 연동되지 않습니다. 보통 yTicks.map(t => t.position)을 넘깁니다.

SvgChartCrosshair

Prop타입기본값
layoutChartLayout— (필수)
xnumber | nullnull
ynumber | nullnull
colorstringvar(--gray-500)
dashArraystring'2 2'

plot 영역을 벗어난 좌표는 자동으로 그리지 않으므로 호출자가 clamp할 필요가 없습니다.

SvgChartPathSeries

Prop타입기본값설명
pathstring— (필수)d3-shape 등이 만든 SVG path 문자열
strokestring'none'
strokeWidthnumber1.5
fillstring'none'
fillOpacitynumber1
opacitynumber1

line은 stroke만, area 계열은 fill + fillOpacity를 지정합니다.

SvgChartRectSeries

Prop타입기본값
rectsreadonly ChartRect[]— (필수)
fillstring— (필수)
opacitynumber1
radiusnumber2

SvgChartState

Prop타입설명
kindChartStateKind'empty' 또는 'error'
titlestring필수
descriptionstring선택
retryLabelstring지정 시 재시도 버튼 노출
typescript
emit('retry')

SvgChartTooltip

Prop타입기본값
visibleboolean— (필수)
titlestring— (필수)
itemsreadonly ChartTooltipItem[]— (필수)
xnumber— (필수)
ynumber— (필수)
offsetnumber8
ariaLabelstringundefined

최대 폭은 CHART_TOOLTIP_MAX_WIDTH(240px)로 제한됩니다.

ChartLegend

Prop타입기본값설명
itemsreadonly ChartLegendItem[]— (필수)
ariaLabelstringundefinedinteractive일 때 버튼 그룹 설명
interactivebooleanfalsetrue면 항목이 토글 버튼이 됨
typescript
emit('toggle', id: string)

범례는 숨김 상태를 소유하지 않습니다. hidden은 호출자가 관리해 items로 다시 내려줘야 합니다.

vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ChartLegend } from '@jennifersoft/apm-components';

const hiddenIds = ref(new Set<string>());

const legendItems = computed(() =>
    series.map((item) => ({
        id: item.id,
        label: item.label,
        color: item.color,
        hidden: hiddenIds.value.has(item.id),
    }))
);

function toggle(id: string) {
    const next = new Set(hiddenIds.value);
    next.has(id) ? next.delete(id) : next.add(id);
    hiddenIds.value = next;
}
</script>

<template>
    <ChartLegend
        :items="legendItems"
        interactive
        aria-label="계열 표시 전환"
        @toggle="toggle"
    />
</template>

기하 계산 헬퍼

Vue 밖에서도 쓸 수 있는 순수 함수입니다.

createCartesianChartGeometry

typescript
function createCartesianChartGeometry(input: {
    type: CartesianChartType;
    data: readonly CartesianChartDataPoint[];
    series: readonly CartesianChartSeries[];
    layout: ChartLayout;
    xTickCount?: number;
    yTickCount?: number;
    xDomain?: readonly [number, number];
    yDomain?: readonly [number, number];
    valueFormatter?: ChartValueFormatter;
}): CartesianChartGeometry;

interface CartesianChartGeometry {
    empty: boolean;
    xTicks: ChartAxisTick[];
    yTicks: ChartAxisTick[];
    pathSeries: { id: string; path: string }[];
    rectSeries: { id: string; rects: ChartRect[] }[];
    xPositions: number[];
}

type에 따라 pathSeriesrectSeries 중 한쪽만 채워집니다.

type채워지는 필드
line, area, stackareapathSeries
column, stackcolumnrectSeries

createChartLinearScale

typescript
function createChartLinearScale(
    domain: readonly number[],
    range: readonly [number, number]
): ScaleLinear<number, number>;

normalizeChartDomain

typescript
function normalizeChartDomain(values: readonly number[]): readonly [number, number];

데이터가 하나뿐이거나 모든 값이 같아 domain이 붕괴하는 경우를 방어합니다.

입력결과
유한값 없음[0, 1]
min ≠ max[min, max]
min === max[min - p, max + p], p = max(abs(min) * 0.1, 1)

createChartAxisTicks

typescript
function createChartAxisTicks(
    scale: ScaleLinear<number, number>,
    format?: (value: number) => string,  // 기본 String
    count?: number                        // 기본 3
): ChartAxisTick[];

상수

typescript
import {
    CHART_DEFAULT_HEIGHT,      // 160
    CHART_DEFAULT_PADDING,     // { top: 8, right: 8, bottom: 24, left: 28 }
    CHART_DEFAULT_TICK_COUNT,  // 3
    CHART_AXIS_FONT_SIZE,      // 11
    CHART_AXIS_LABEL_OFFSET,   // 6
    CHART_SERIES_STROKE_WIDTH, // 1.5
} from '@jennifersoft/apm-components';

직접 조립 예제

primitive만으로 축 없는 스파크라인을 만드는 예제입니다.

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
    SvgChartFrame,
    SvgChartPathSeries,
    createCartesianChartGeometry,
} from '@jennifersoft/apm-components';
import type { ChartLayout } from '@jennifersoft/apm-components';

const layout = ref<ChartLayout | null>(null);

const series = [{ id: 'tps', label: 'TPS', color: 'var(--blue-500)' }];
const data = [
    { x: 1, label: '10:00', values: { tps: 120 } },
    { x: 2, label: '10:01', values: { tps: 138 } },
    { x: 3, label: '10:02', values: { tps: 96 } },
];
</script>

<template>
    <SvgChartFrame
        :height="40"
        :padding="{ top: 2, right: 2, bottom: 2, left: 2 }"
        @layout-change="layout = $event"
    >
        <template v-if="layout">
            <SvgChartPathSeries
                v-for="item in createCartesianChartGeometry({
                    type: 'line',
                    data,
                    series,
                    layout,
                }).pathSeries"
                :key="item.id"
                :path="item.path"
                stroke="var(--blue-500)"
            />
        </template>
    </SvgChartFrame>
</template>

주의사항

  • datax 오름차순으로 정렬해서 넘겨야 합니다. 정렬은 컴포넌트가 하지 않습니다.
  • series[].iddata[].values의 키가 일치해야 합니다. 불일치하면 조용히 결측 처리됩니다.
  • color는 CSS 변수를 권장합니다. 다크 테마 전환 시 하드코딩된 hex는 대비가 깨집니다.
  • ariaLabel은 필수입니다. SvgChartFrame을 직접 쓸 때 생략하면 SVG가 보조 기술에서 숨겨집니다.