Skip to content

SVG Charts ​

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

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 패턴은 values에 null을 넣어 결측 처리를 확인할 수 있습니다. 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;

values가 null / 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개는 SvgCartesianChart에 type만 고정한 래퍼입니다. 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에 따라 pathSeries와 rectSeries 중 한쪽만 채워집니다.

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>

주의사항 ​

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