Appearance
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를 모르고 계산된 화면 좌표만 받으므로, 다른 차트를 조립할 때 그대로 재사용할 수 있습니다.
렌더링 흐름
SvgChartFrame이 컨테이너 폭을useResizeObserver로 관측해ChartLayout을 계산하고layout-change로 올려보냅니다.SvgCartesianChart가 그layout과 props(data,series,type)로createCartesianChartGeometry()를 호출합니다.- 반환된
xTicks/yTicks/pathSeries/rectSeries/xPositions를 각 primitive에 넘깁니다. - 마우스 이동 시
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 | 타입 | 기본값 | 설명 |
|---|---|---|---|
ariaLabel | string | — (필수) | 차트 전체를 설명하는 접근성 이름 |
type | CartesianChartType | — (필수) | 차트 종류 |
data | readonly CartesianChartDataPoint[] | — (필수) | x축 순서대로 정렬된 데이터 |
series | readonly CartesianChartSeries[] | — (필수) | 그릴 series 목록 |
width | number | undefined | 미지정 시 컨테이너 폭 관측 |
height | number | 160 | CHART_DEFAULT_HEIGHT |
padding | ChartPadding | {top:8,right:8,bottom:24,left:28} | plot 바깥 여백 |
xTickCount | number | 3 | X축 눈금 개수 |
yTickCount | number | 3 | Y축 눈금 개수 |
xDomain | readonly [number, number] | undefined | 미지정 시 데이터에서 유도 |
yDomain | readonly [number, number] | undefined | 미지정 시 데이터에서 유도 |
valueFormatter | ChartValueFormatter | undefined | Y축 눈금·툴팁 값 포맷 |
showLegend | boolean | true | 범례 표시 여부 |
state | ChartStateView | undefined | 지정 시 데이터와 무관하게 상태 화면 강제 |
emptyState | Omit<ChartStateView, 'kind'> | undefined | 데이터가 없을 때 보여줄 문구 |
이벤트
typescript
emit('retry') // 상태 화면의 retryLabel 버튼 클릭 시상태 화면 우선순위
state가 있으면 항상 그 상태를 표시합니다 (에러 표시에 사용).state가 없고 데이터가 비었으며emptyState가 있으면kind: 'empty'로 표시합니다.- 둘 다 없으면 빈 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 | 용도 |
|---|---|---|
SvgLineChart | line | 추이 비교. 0을 domain에 포함하지 않음 |
SvgAreaChart | area | 단일 계열 볼륨 강조. baseline은 plot 하단 |
SvgColumnChart | column | 시점별 비교. series 개수만큼 그룹 내 분할 |
SvgStackAreaChart | stackarea | 구성비 누적 추이 |
SvgStackColumnChart | stackcolumn | 구성비 누적 비교 |
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 | 타입 | 기본값 | 설명 |
|---|---|---|---|
ariaLabel | string | undefined | 없으면 SVG를 보조 기술에서 숨김 |
width | number | undefined | 미지정 시 컨테이너 폭 관측 |
height | number | 160 | |
padding | ChartPadding | CHART_DEFAULT_PADDING |
typescript
emit('layout-change', layout: ChartLayout) // 마운트 직후 즉시 1회 + 리사이즈마다SvgChartAxis
| Prop | 타입 | 기본값 |
|---|---|---|
layout | ChartLayout | — (필수) |
xTicks | readonly ChartAxisTick[] | [] |
yTicks | readonly ChartAxisTick[] | [] |
color | string | var(--gray-600) |
edgeColor | string | var(--gray-500) |
fontSize | number | 11 |
xLabelOffset | number | 6 |
yLabelOffset | number | 6 |
showXAxis | boolean | true |
showYAxis | boolean | true |
SvgChartGrid
| Prop | 타입 | 기본값 |
|---|---|---|
layout | ChartLayout | — (필수) |
horizontalPositions | readonly number[] | [] |
verticalPositions | readonly number[] | [] |
stroke | string | var(--gray-200) |
strokeWidth | number | 1 |
격자 위치는 눈금과 자동으로 연동되지 않습니다. 보통 yTicks.map(t => t.position)을 넘깁니다.
SvgChartCrosshair
| Prop | 타입 | 기본값 |
|---|---|---|
layout | ChartLayout | — (필수) |
x | number | null | null |
y | number | null | null |
color | string | var(--gray-500) |
dashArray | string | '2 2' |
plot 영역을 벗어난 좌표는 자동으로 그리지 않으므로 호출자가 clamp할 필요가 없습니다.
SvgChartPathSeries
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
path | string | — (필수) | d3-shape 등이 만든 SVG path 문자열 |
stroke | string | 'none' | |
strokeWidth | number | 1.5 | |
fill | string | 'none' | |
fillOpacity | number | 1 | |
opacity | number | 1 |
line은 stroke만, area 계열은 fill + fillOpacity를 지정합니다.
SvgChartRectSeries
| Prop | 타입 | 기본값 |
|---|---|---|
rects | readonly ChartRect[] | — (필수) |
fill | string | — (필수) |
opacity | number | 1 |
radius | number | 2 |
SvgChartState
| Prop | 타입 | 설명 |
|---|---|---|
kind | ChartStateKind | 'empty' 또는 'error' |
title | string | 필수 |
description | string | 선택 |
retryLabel | string | 지정 시 재시도 버튼 노출 |
typescript
emit('retry')SvgChartTooltip
| Prop | 타입 | 기본값 |
|---|---|---|
visible | boolean | — (필수) |
title | string | — (필수) |
items | readonly ChartTooltipItem[] | — (필수) |
x | number | — (필수) |
y | number | — (필수) |
offset | number | 8 |
ariaLabel | string | undefined |
최대 폭은 CHART_TOOLTIP_MAX_WIDTH(240px)로 제한됩니다.
ChartLegend
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
items | readonly ChartLegendItem[] | — (필수) | |
ariaLabel | string | undefined | interactive일 때 버튼 그룹 설명 |
interactive | boolean | false | true면 항목이 토글 버튼이 됨 |
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, stackarea | pathSeries |
column, stackcolumn | rectSeries |
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가 보조 기술에서 숨겨집니다.