React Native 에서 차트 구현 - 4. 라인차트 아이템 선택 기능
1. 개요
이전 글까지는 라인 차트의 기본 기능을 구현하고, 옵션 기능을 구현하고, 레전드(범례) 기능까지 구현했다.
오늘은 터치를 통해 특정 아이템을 선택하는 기능을 구현하자.
2. 구현
요구사항이나 차트 라이브러리에 따라 다르긴 하지만, 보통 차트에서 특정 아이템을 선택하면 아래 하이차트 예제처럼 세 가지 정도가 나타난다.
- 선택된 아이템들을 표시하는 세로선
- 시리즈 라인 별로 선택된 아이템을 표시하는 점
- 선택된 아이템들의 정보를 표시하는 툴팁
차근차근 구현해보자.
2.1. 터치된 지점에서 제일 가까운 아이템 찾기
일단 선택된 아이템들을 어떻게 표시할지를 구현하기에 앞서, 터치된 지점의 X 좌표를 갖고 해당 지점과 가장 가까운 아이템을 찾는 로직을 먼저 작성하자.
2.1.1. 터지된 지점 좌표값 가져오기
터치 이벤트는 <View />
의 onTouchStart
, onTouchMove
, onTouchEnd
prop 들로 받아올 수 있다. 해당 이벤트 리스너를 통해 터치 지점의 좌표를 얻을 수 있다.
function LineChart(/* ... */) {
return (
<View /* ... */>
<View
style={styles.chartWrapper}
onTouchStart={(evt) => { // 터치된 지점의 X 좌표는 아래 값으로 확인할 수 있다. // `onTouchMove`, `onTouchEnd` 도 마찬가지. evt.nativeEvent.touches[0].locationX; }} >
<Svg width="100%" height={height} onLayout={onLayout}>
{loaded && <LineChartBody /* ... */ />}
</Svg>
</View>
<Legend /* ... */ />
</View>
);
}
2.1.2. 좌표값에서 제일 가까운 아이템 가져오기
터치 이벤트 리스너를 통해 가져온 좌표값으로 제일 가까운 아이템을 찾는 함수를 작성해보자. 해당 기능은 D3.js 의 bisector
를 사용해 구현할 수 있다.
import { ScaleTime, bisector } from "d3";
import isSameDay from "utils/isSameDay";
import { TimeSeries } from "./types";
// `bisector` 는 "좌표값을 받아서 가장 가까운 아이템을 반환하는 함수"를 만들어준다.
const bisect = bisector<TimeSeries["data"][0], Date>((it) => it.date).center;
type FindItemsByCoordOptions = {
// 가까운 아이템을 찾고자 하는 대상 시리즈들
series: TimeSeries[];
// 차트의 가로 범위
range: [number, number];
// 좌표값 <-> 날짜값 변환을 위한 스케일 함수
scale: ScaleTime<number, number, never> | null;
// 터치된 지점의 X 좌표
x: number;
};
function findItemsByCoord({
series,
range,
scale,
x,
}: FindItemsByCoordOptions) {
if (series.length === 0) {
return null;
}
if (!scale) {
return null;
}
// `series`를 순회하면서 시리즈별로 가장 가까운 아이템을 찾는다.
// 찾은 아이템들 중 좌표에 가장 가까운 아이템이 선택된 아이템이 된다.
let minDistance = range[1] - range[0];
let date: Date | undefined = undefined;
for (let i = 0; i < series.length; i++) {
const index = bisect(series[i].data, scale.invert(x), 0);
const found = series[i].data[index];
if (!found) {
continue;
}
const distance = Math.abs(scale(found.date) - x);
if (minDistance > distance) {
minDistance = distance;
date = found.date;
}
}
// 선택된 아이템이 없다면 `null` 을 반환한다.
// 모든 시리즈의 `data` 가 비어있지 않은 이상 발생하지 않을 일이긴 하다.
if (!date) {
return null;
}
// 위에서 찾은 아이템과 같은 날짜의 아이템을 모든 시리즈로부터 찾아서 같이 반환한다.
// (주의할 점은 이 차트가 일별 차트라는 점을 가정에 둔 코드라는 것이다.
// 만약 시간까지 세분화된 차트라면 이 코드로는 올바른 값을 구할 수 없다.)
return series.map((sr, i) => {
if (!sr.visible) {
return null;
}
const found = sr.data.find((item) => isSameDay(item.date, date!));
// 현재 더미 데이터에는 모든 데이터가 기간안에 일별로 가득 차 있지만
// 실제 상황에서는 특정 날짜의 데이터가 없을 수도 있다.
// 그럴 경우에는 아이템을 찾을 수 없으므로 null 을 넣어준다.
if (!found) {
return null;
}
// `seriesIndex` 값을 가지고 어떤 시리즈에 속한 아이템인지 구분한다.
// 해당 시리즈의 속성 (`name`, `color` 등) 을 가져올 때 사용한다.
return { ...found, seriesIndex: i };
});
}
export default findItemsByCoord;
이제 <LineChart />
에서 선택된 지점의 좌표와 이 함수를 사용해 선택된 아이템을 찾아서 <LineChartBody />
에 넘겨주자.
// ...
import findItemsByCoord from "./findItemsByCoord";
// ...
type SelectedItem = TimeSeriesDatum & { seriesIndex: number };
function LineChart(/* ... */) {
const [state, setState] = useImmer({
// ...
selected: null as null | (SelectedItem | null)[], });
// ...
// 터치 이벤트 리스너. const onTouchStart = useCallback<ViewProps["onTouchStart"]>( (touches) => { // 터치가 발생한 지점의 X 좌표로 가장 가까운 아이템이 무엇인지 찾아낸다. const selected = findItemsByCoord({ series: state.series, range: paneBoundary.xs, scale: xScale, x: touches[0].locationX, }); // 찾아낸 아이템을 state 에 저장한다. setState((dr) => { dr.selected = selected; }); }, [state.series, paneBoundary] );
// `onTouchMove` 도 `onTouchStart` 와 같은 코드를 사용한다. const onTouchMove = onTouchStart;
return (
<View /* ... */>
<View
style={styles.chartWrapper}
onTouchStart={onTouchStart} onTouchMove={onTouchMove} >
<Svg width="100%" height={height} onLayout={onLayout}>
{loaded && (
<LineChartBody
// ...
// 저장된 터치 지점을 넘겨준다. selected={state.selected} />
)}
</Svg>
</View>
<Legend /* ... */ />
</View>
);
}
이제 터치를 통해 선택된 아이템이 <LineChart />
의 state.selected
에 저장된다.
2.2. 선택된 아이템들을 표시하는 세로선과 점 구현
어떤 아이템이 선택되었는지는 알게 되었으니, 선택된 아이템들을 표시할 <Selection />
컴포넌트를 구현하자.
import { ScaleLinear, ScaleTime } from "d3";
import { Circle, G, Line } from "react-native-svg";
import PaneBoundary from "utils/PaneBoundary";
import getColorWithAlpha from "utils/getColorWithAlpha";
import { SelectedItem } from "../types";
type SelectionProps = {
// 선택된 아이템들의 세로선 및 점을 표시하기 위한 scale 함수
xScale: ScaleTime<number, number, never>;
// 선택된 아이템들의 점을 표시하기 위한 scale 함수
yScale: ScaleLinear<number, number, never>;
// 차트의 영역
paneBoundary: PaneBoundary;
// 선택된 아이템들
selected: null | (SelectedItem | null)[];
// 시리즈별 색상. 선택된 아이템들의 점을 표시할 때 사용된다.
colors: string[];
};
function Selection({
xScale,
yScale,
paneBoundary,
selected,
colors,
}: SelectionProps) {
const firstItem = selected?.find((it) => !!it);
// 선택된 아이템이 없다면 아무것도 출력하지 않는다.
if (!firstItem) {
return null;
}
return (
<G>
{/* 선택된 아이템들에 세로선을 표시한다. */}
<Line
x1={xScale(firstItem.date)}
x2={xScale(firstItem.date)}
y1={paneBoundary.y1}
y2={paneBoundary.y2}
stroke="gray"
/>
{/* 선택된 아이템들의 위치에 점을 표시한다. */}
{selected?.map((item) =>
!item ? null : (
<Circle
key={item.seriesIndex}
x={xScale(item.date)}
y={yScale(item.value)}
r={2}
fill={colors[item.seriesIndex % colors.length]}
stroke={getColorWithAlpha(
colors[item.seriesIndex % colors.length],
0.5
)}
strokeWidth={3}
/>
)
)}
</G>
);
}
export default Selection;
이 <Selection />
컴포넌트를 <LineChartBody />
에서 사용해주면 된다.
// ...
type LineChartBodyProps = {
// ...
selected: null | (SelectedItem | null)[];};
function LineChartBody({
// ...
selected,}: LineChartBodyProps) {
return (
<Fragment>
<XAxis /* ... */ />
<YAxis /* ... */ />
<Lines /* ... */ />
<Selection xScale={xScale} yScale={yScale} paneBoundary={paneBoundary} selected={selected} colors={linesOptions?.colors ?? DEFAULT_COLORS} /> </Fragment>
);
}
export default LineChartBody;
이제 차트를 터치하면 터지한 지점과 가장 가까운 아이템이 점과 세로선으로 표시되는 것을 확인할 수 있다.
2.3. 선택된 아이템들의 정보를 표시하는 툴팁 구현
터치된 지점이 세로선과 점으로 강조되는 것은 매우 좋지만, 해당 지점의 정보를 볼 수 없으니 정확히 어떤 점을 선택했는지 알기 어렵다. 터지한 지점의 정보를 보여주는 툴팁을 구현하자.
2.3.1. <Selection />
컴포넌트 분리
일단 <Selection />
컴포넌트를 수정하자.
본래는 여기에 툴팁 기능까지 같이 구현할 생각이었지만, 가독성 및 확장성을 위해 컴포넌트를 나누도록 하겠다. 세로선을 그린 <Line />
은 그대로 두고 점들을 그린 items.map()
구문은 <Dots />
컴포넌트로 분리할 것이며 툴팁 또한 <Tooltip />
컴포넌트를 만들어 따로 구현할 것이다. 그러면 아래와 같은 형태가 될 것이다.
// ...
function Selection(/* ... */) {
// ...
return (
<>
<G>
{lineWidth > 0 && (
<Line
x1={x}
x2={x}
y1={paneBoundary.y1}
y2={paneBoundary.y2}
stroke={lineColor}
strokeWidth={lineWidth}
/>
)}
<Dots items={selected} xScale={xScale} yScale={yScale} colors={colors} /> </G>
<Tooltip /> </>
);
}
<Dots />
는 아래와 같다. 이전에 <Selection />
에 있던 내용 중 일부를 그대로 옮겨 간 코드다.
import { Circle, G } from "react-native-svg";
import { ScaleLinear, ScaleTime } from "d3";
import getColorWithAlpha from "utils/getColorWithAlpha";
import { SelectedItem } from "../../types";
type DotsProps = {
items: SelectedItem[];
xScale: ScaleTime<number, number, never>;
yScale: ScaleLinear<number, number, never>;
colors: string[];
};
function Dots({ items, xScale, yScale, colors }: DotsProps) {
return (
<G>
{items.map((item) => (
<Circle
key={item.seriesIndex}
x={xScale(item.date)}
y={yScale(item.value)}
r={2}
fill={colors[item.seriesIndex % colors.length]}
stroke={getColorWithAlpha(
colors[item.seriesIndex % colors.length],
0.5
)}
strokeWidth={3}
/>
))}
</G>
);
}
export default Dots;
2.3.2. <Tooltip />
구현
<Tooltip />
컴포넌트도 구현해보자. 이 컴포넌트에 들어갈 내용은
- X 축 정보
- 각 점의 시리즈 이름과 값
정도가 있다. 해당 정보를 출력하자.
import { Circle, G, GProps, TSpan, Text } from "react-native-svg";
import { SelectedItem, TimeSeries } from "../../types";
export type TooltipProps = {
// 선택된 아이템들
items: SelectedItem[];
// 시리즈들. 아이템의 시리즈 이름을 표시하기 위해 가져왔다.
series: TimeSeries[];
// 색상들. 아이템의 색상을 적용하기 위해 가져왔다.
colors: string[];
};
function Tooltip({ items, series, colors }: TooltipProps) {
return (
<G>
{/* Y 축 정보를 출력한다. */}
<G y={0}>
<Text alignmentBaseline="hanging">{dateFormat(items[0].date)}</Text>
</G>
{/*
`items` 를 선회하면서 각 아이템의 정보를 출력한다.
시리즈의 이름과 색상이 필요하므로 `series` 와 `colors` 도 받아와서 사용하고 있다.
*/}
{items.map((item, i) => (
<G key={item.seriesIndex} y={i * 14}>
<Circle
x={2}
y={5}
r={2}
fill={colors[item.seriesIndex % colors.length]}
/>
<Text dx={8} alignmentBaseline="hanging">
<TSpan>{`${series[item.seriesIndex].name}:`}</TSpan>
<TSpan dx={5} fontWeight="600">
{item.value}
</TSpan>
</Text>
</G>
))}
</G>
);
}
export default Tooltip;
또한 툴팁은 하얀 배경과 회색 테두리를 갖게 될텐데, 툴팁의 내용에 따라 해당 배경 및 테두리의 너비/높이가 바뀌어야 하므로 해당 구현도 필요하다.
그냥 툴팁의 컨텐츠의 컨테이너인 <G />
에 배경색(fill
) 및 테두리(stroke*
) 속성을 지정해서 적용하고 싶지만 그렇게는 할 수 없다. <G />
컴포넌트는 SVG 에서 다른 컴포넌트들을 그룹핑하기 위해서 쓰이는 컴포넌트로 스타일을 지정할 수 없기 때문이다. <G />
에게 스타일 속성을 지정하면 해당 컴포넌트의 자식 컴포넌트들에게만 영향이 갈 뿐 본인에게는 어떠한 스타일도 적용되지 않는다. (이는 웹에서도 동일하다.)
그러니 우리는 onLayout
리스너를 통해 <G />
의 크기를 알아낸 다음, 해당 크기와 동일한 <Rect />
를 그려서 배경 및 테두리를 구현할 것이다.
import { Rect /* ... */ } from "react-native-svg";
import { useImmer } from "use-immer";
// ...
function Tooltip({ items, series, colors }: TooltipProps) {
const [state, setState] = useImmer({
width: 0,
height: 0,
});
// 컨텐츠 영역 (Y 축 정보, 각 점의 이름과 값 정보 영역) 의 컨테이너인 `<G />` 의 크기를 얻기 위한 리스너 const onContentLayout: GProps["onLayout"] = (evt) => { const { layout } = evt.nativeEvent; const newWidth = Math.floor(layout.width); const newHeight = Math.floor(layout.height); if (state.width === newWidth && state.height === newHeight) { return; } setState((dr) => { dr.width = newWidth; dr.height = newHeight; }); };
const loaded = state.width !== 0 && state.height !== 0;
return (
// 컨텐츠 영역의 크기가 측정되어서 배경을 그리기 전까지는 // opacity 를 0 으로 해서 보여주지 않는다. <G opacity={loaded ? 1 : 0}> {loaded && (
// 컨텐츠 영역의 너비/높이가 측정되었다면 <Rect /> 로 배경색과 테두리를 그린다. // `+ 20` 은 좌우 여백을 위한 값이다. <Rect width={state.width + 20} height={state.height + 20} fill="white" stroke="darkgray" /> )}
<G x={10} y={10} onLayout={onContentLayout}> <G y={0}>
<Text alignmentBaseline="hanging">{dateFormat(items[0].date)}</Text>
</G>
{items.map((item, i) => (
// ...
))}
</G>
</G>
);
}
export default Tooltip;
마지막으로 <LineChartBody />
에서 <Selection />
을 통해 <Tooltip />
에까지 series
를 건내주도록 수정하자.
// ...
function LineChartBody(/* ... */) {
return (
<Fragment>
<XAxis /* ... */ />
<YAxis /* ... */ />
<Lines /* ... */ />
<Selection
xScale={xScale}
yScale={yScale}
paneBoundary={paneBoundary}
selected={selected}
colors={linesOptions?.colors ?? DEFAULT_COLORS}
series={series} // <- series 를 추가했다. />
</Fragment>
);
}
export default LineChartBody;
// ...
type SelectionProps = SelectionOptions & {
// ...
series: TimeSeries[];};
function Selection({
// ...
series,}) {
// ...
return (
<>
{/* ... */}
<Tooltip items={selected} series={series} colors={colors} /> </>
);
}
2.3.3. 툴팁의 위치를 동적으로 변경
툴팁의 배경까지 그렸지만 아직 조금 부족하다. 왜냐하면 이대로라면 이 툴팁은 (0, 0) 좌표에 고정되어 있을 것이기 때문이다. 선택된 아이템을 위한 툴팁이라면 선택된 아이템의 세로선 및 점 옆에 따라다니는 것이 더 자연스럽다. 위치도 동적으로 바꿀 수 있도록 조금만 더 고쳐보자.
먼저 <Tooltip />
에서 크기 정보를 부모에게 올려줄 수 있도록 onLayout
prop 을 추가하자.
<Tooltip />
의 크기를 부모에게 올려주는 이유는 부모가 툴팁의 위치를 계산할 때 해당 값이 필요하기 때문이다.
툴팁의 위치는 툴팁 자신의 크기에 따라 바뀔 수 있다. 예를 들어 본래라면 선택된 아이템 세로선의 오른쪽에 툴팁이 그려져야 하는데 세로선 오른쪽의 공간이 툴팁의 너비보다 좁다면, 툴팁을 세로선 왼쪽에 그리는 것이 자연스러울 것이다.
// ...
export type TooltipProps = {
// ...
// 이 컴포넌트의 위치를 지정할 수 있도록 제공하는 props x?: number; y?: number; // 부모에게 제공할 `onLayout` 이벤트 핸들러 onLayout?: GProps["onLayout"];};
function Tooltip({ x = 0, y = 0, onLayout: _onLayout }: TooltipProps) { // ...
const onLayout: GProps["onLayout"] = (evt) => { const { layout } = evt.nativeEvent; if (layout.width === 0 || layout.height === 0) { return; } // 부모에게 onLayout 이벤트를 그대로 올려준다. _onLayout?.(evt); };
// ...
return (
<G x={x} y={y} opacity={loaded ? 1 : 0}> {loaded && (
<Rect
width={state.width + 20}
height={state.height + 20}
fill="white"
stroke="darkgray"
// 최상단 컴포넌트에 `onLayout` 을 넣지 않고 여기 배경 컴포넌트에 넣어둔 이유는 // 1. 이 컴포넌트의 크기가 `<Tooltip />` 컴포넌트의 크기와 동일하고 // 2. 이 컴포넌트가 그려진 순간이 `<Tooltip />` 의 초기화가 끝난 순간이기 때문이다. // 만약 최상단 컴포넌트에 `onLayout` 을 넣었다면 // 컨텐츠 영역만 그려졌을 때 한 번, 배경까지 그려졌을 때 한 번, // 이렇게 최소한 두 번 `onLayout` 이 호출되기 때문에 비효율적이다. onLayout={onLayout} />
)}
<G x={10} y={10} onLayout={onContentLayout}>
{/* ... */}
</G>
</G>
);
}
export default Tooltip;
이제 <Tooltip />
의 부모인 <Selection />
에서 툴팁의 위치를 계산하자.
// ...
function Selection({
xScale,
yScale,
paneBoundary,
selected,
colors,
series,
}: SelectionProps) {
const [state, setState] = useImmer({
tooltip: { width: 0, height: 0, }, });
// 선택된 아이템들의 X 좌표를 계산한다. const firstItem = selected.find((it) => !!it); const x = firstItem && xScale(firstItem.date);
// `<Tooltip />` 의 `onLayout` prop 을 통해 툴팁의 크기를 가져온다. const onTooltipLayout: TooltipProps["onLayout"] = (evt) => { const { layout } = evt.nativeEvent; const newWidth = Math.floor(layout.width); const newHeight = Math.floor(layout.height); if ( state.tooltip.width === newWidth && state.tooltip.height === newHeight ) { return; } setState((dr) => { dr.tooltip.width = newWidth; dr.tooltip.height = newHeight; }); };
if (x === undefined) {
return null;
}
// 선택된 아이템들의 X 좌표와 툴팁의 크기를 가지고 툴팁의 X 좌표를 계산한다. // 1. 툴팁의 너비가 0이면 아직 로딩 전이라는 뜻이므로 // 그냥 안 보이게 먼 곳(-1000)으로 보내버린다. // 2. 선택된 X 좌표 오른쪽 공간의 너비가 툴팁이 너비보다 넓으면 (공간이 충분하면) // 툴팁을 해당 좌표 오른쪽에 출력한다. // 3. 선택된 X 좌표 오른쪽 공간의 너비가 툴팁의 너비보다 좁으면 (공간이 부족하면) // 툴팁을 해당 좌표 왼쪽에 출력한다. const tooltipX = state.tooltip.width === 0 ? -1000 : paneBoundary.x2 - state.tooltip.width > x + 10 ? x + 10 : x - state.tooltip.width - 10;
return (
<>
{/* ... */}
<Tooltip
// ...
x={tooltipX} y={paneBoundary.y2} // Y 좌표는 차트 영역 최상단으로 고정 onLayout={onTooltipLayout} />
</>
);
}
이제 툴팁이 선택된 아이템들을 따라다니는 것을 확인할 수 있다.
2.4. 선택 취소하기
여기까지 구현해서 사용해보면 한 가지 불편한 사항이 있다. 바로 아이템 선택을 취소하고 싶은데 취소할 방법이 없다는 것이다. 차트에서 선택을 취소하게끔 하려면 아래 방법들을 제공할 수 있을 것이다.
- 차트 바깥 부분을 터치
- 선택을 취소하는 기능의 버튼 등을 별도로 제공
- 아이템이 선택된 차트를 아주 짧게 터치
- 애시당초 터치하고 있을 때만 선택할 수 있도록 (터치를 떼는 순간 선택이 취소되도록) 구현
어느 방법이든 선택의 차이일 뿐 불가능하지 않다. (다만 1번 방법 같은 경우, 웹에서는 구현이 쉽지만 RN 에서는 매끄럽게 구현하기 어렵다.)
이 글에서는 3번 방법을 선택하도록 하겠다. (만약 다른 방법이 더 마음에 든다면 다른 방법으로 구현해도 문제 없다.) 3번 방법을 좀 더 자세히 정리하자면 아래와 같다.
- 아이템이 선택되지 않은 상태: 차트를 터치하면 터치된 지점의 가까운 아이템이 선택됨
- 아이템이 선택된 상태:
- 차트를 아주 짧게 터치하면 선택이 취소됨
- 차트를 일정 시간 이상 길게 터치하면 터치된 지점의 가까운 아이템이 선택됨
2.4.1. onTouchStart
, onTouchMove
, onTouchEnd
각각 구현
2.1.2. 에서는 onTouchStart
와 onTouchMove
에 같은 리스너를 지정하고 onTouchEnd
에는 아무 리스너도 지정하지 않았다. 하지만 위에서 언급한 요구사항에 만족하기 위해서는 세 리스너를 모두 각각 구현해야 한다.
이 리스너들과 가까운 아이템을 찾는 로직을 모두 <LineChart />
에 구현하면 코드가 정신없어질 것 같으니 코드를 분리하도록 하겠다.
// ...
import useSelectionByTouch from "./useSelectionByTouch";
function LineChart(/* ... */) {
// ...
// - 2.1.2. 에서 구현한 `onTouchStart`와 `state.selected` 는 모두 // `useSelectionByTouch` 로 이동할 것이므로 이 파일에서는 삭제된다. // - `touchCallbacks` 에 `onTouchStart`, `onTouchMove`, `onTouchEnd` 가 모두 포함되어있다. const [selected, touchCallbacks] = useSelectionByTouch( state.series, xScale, paneBoundary );
// ...
return (
<View /* ... */>
{/* `touchCallbacks` 를 여기에 적용한다. */} <View style={styles.chartWrapper} {...touchCallbacks}> <Svg width="100%" height={height} onLayout={onLayout}>
{loaded && (
<LineChartBody
// ...
// `useSelectionByTouch` 에서 받아온 선택된 아이템(`selected`)은 여기에 전달한다. selected={selected} />
)}
</Svg>
</View>
{/* ... */}
</View>
);
}
그러면 useSelectionByTouch
도 구현하자.
// ...
// 처음 나오는 `useSelectionByTouch.ts` 코드 전문이지만
// 코드가 길어져서 import 문 및 타입 지정은 생략한다.
const MIN_TOUCH_DURATION = 100;
function useSelectionByTouch(
series: TimeSeries[],
xScale: ReturnType<typeof getTimeScale>,
paneBoundary: PaneBoundary
): UseSelectionByTouchReturns {
const touchRef = useRef({
// 터치가 되었는지 (그래서 선택된 아이템이 있는지) 여부를 저장한다.
touched: false,
// `onTouchStart` 이벤트 발생 시점을 저장한다.
// 이 값은 짧은 터치를 했는지 구분할 때 쓰인다.
touchStartTimestamp: 0,
});
const [state, setState] = useImmer<UseSelectionByTouchState>({
// 선택된 아이템이 있을 경우, 터치된 지점도 같이 저장한다.
touchedX: 0,
// 선택된 아이템들을 저장한다.
selected: null,
});
// `series` 가 바뀔 경우 저장된 좌표를 기반으로 선택된 아이템을 다시 계산한다.
// `<Legend />` 의 특정 시리즈 비활성홬 기능을 염두에 둔 코드다.
useEffect(() => {
if (touchRef.current.touched) {
const selected = findItemsByCoord({
series: series,
range: paneBoundary.xs,
scale: xScale,
x: state.touchedX,
});
setState((dr) => {
dr.selected = selected;
});
}
}, [series]);
const onTouchStart = useCallback<OnTouchStart>(() => {
// 터치가 시작되면 터치 시작 시간을 `touchRef` 에 저장해둔다.
touchRef.current.touchStartTimestamp = new Date().getTime();
}, []);
const onTouchMove = useCallback<OnTouchMove>(
(evt) => {
const now = new Date().getTime();
// 이미 선택된 아이템이 있는 상태라면
// 그리고 최소터치시간(`MIN_TOUCH_DURATION`) 이 지나지 않았다면
// -> 일단 아무 일도 하지 않는다. 이 터치가 선택 취소를 위한 터치일 가능성이 아직 있기 때문이다.
if (
touchRef.current.touched &&
touchRef.current.touchStartTimestamp + MIN_TOUCH_DURATION > now
) {
return;
}
// 선택된 아이템이 없거나
// 혹은 최소터치시간(`MIN_TOUCH_DURATION`) 이 지났다면
// -> 새로운 좌표에 기반해 선택 아이템을 찾기 시작한다.
// 최소터치시간이 지났기 때문에 이 터치는 절대 선택 취소 터치가 아니다.
const { touches } = evt.nativeEvent;
const selected = findItemsByCoord({
series: series,
range: paneBoundary.xs,
scale: xScale,
x: touches[0].locationX,
});
setState((dr) => {
dr.touchedX = touches[0].locationX;
dr.selected = selected;
});
},
[series, paneBoundary]
);
const onTouchEnd = useCallback<OnTouchEnd>((evt) => {
const now = new Date().getTime();
// 선택된 아이템이 있는데
// 최소터치시간(`MIN_TOUCH_DURATION`) 이 지나기 전에 이 `onTouchEnd` 가 호출되었다면
// -> 선택 취소 터치이므로 아이템 선택을 취소한다.
if (
touchRef.current.touched &&
touchRef.current.touchStartTimestamp + MIN_TOUCH_DURATION > now
) {
touchRef.current.touched = false;
setState((dr) => {
dr.touchedX = 0;
dr.selected = null;
});
// 선택 취소 터치가 아니었다면 아이템이 선택되었음을 마킹해둔다.
} else {
touchRef.current.touched = true;
}
}, []);
// 현재 선택된 아이템과 터치 관련 리스너들을 반환한다.
return [state.selected, { onTouchStart, onTouchMove, onTouchEnd }];
}
export default useSelectionByTouch;
이제 아이템이 선택된 상태에서 차트를 짧게 터치하면 선택이 취소되는 것을 볼 수 있다.
2.5. SelectionOptions
이제 마지막으로, 여태까지 구현해온 다른 기능들도 그랬듯이, 옵션 기능을 구현해보자.
2.5.1. 옵션 타입 추가
일단 옵션의 타입부터 추가하자.
export type SelectionOptions = {
enabled?: boolean;
lineColor?: string;
lineWidth?: number;
dot?: {
enabled?: boolean;
color?: string | ((seriesColor: string) => string);
radius?: number;
borderColor?: string | ((seriesColor: string) => string);
borderWidth?: number;
};
tooltip?: {
enabled?: boolean;
padding?: number;
paddingTop?: number;
paddingLeft?: number;
paddingRight?: number;
paddingBottom?: number;
backgroundColor?: string;
borderColor?: string;
borderWidth?: number;
titleFormatter?: (items: SelectedItem[]) => string;
titleSize?: number;
titleFont?: string;
titleWeight?: FontWeight;
titleColor?: string;
itemCircleRadius?: number;
itemCircleColor?: string | ((seriesColor: string) => string);
itemNameFormatter?: (item: SelectedItem, series: TimeSeries[]) => string;
itemNameSize?: number;
itemNameFont?: string;
itemNameWeight?: FontWeight;
itemNameColor?: string | ((seriesColor: string) => string);
itemValueFormatter?: (item: SelectedItem) => string;
itemValueSize?: number;
itemValueFont?: string;
itemValueWeight?: FontWeight;
itemValueColor?: string | ((seriesColor: string) => string);
titleHeight?: number;
itemHeight?: number;
itemCircleNameGap?: number;
itemNameValueGap?: number;
};
};
다소 길다.
dot
과 tooltip
필드를 객체로 구성한 이유는 <Selection />
이 <Dots />
, <Tooltip />
에게 옵션을 전달할 때 편의성을 위해서다.
2.5.2. <LineChart />
로부터 옵션 전달
<LineChart />
가 해당 옵션을 prop 으로 받을 수 있게끔 하고, <LineChart />
로부터 <Selection />
까지 옵션값을 전달하자.
type LineChartProps = {
// ...
selectionOptions?: SelectionOptions;};
function LineChart({
// ...
selectionOptions,}: LineChartProps) {
// ...
return (
<View /* ... */>
<View style={styles.chartWrapper} {...touchCallbacks}>
<Svg width="100%" height={height} onLayout={onLayout}>
{loaded && (
<LineChartBody
// ...
selectionOptions={selectionOptions} />
)}
</Svg>
</View>
{/* ... */}
</View>
);
}
type LineChartBodyProps = {
// ...
selectionOptions?: SelectionOptions;};
function LineChartBody({
// ...
selectionOptions,}: LineChartBodyProps) {
return (
<Fragment>
<XAxis /* ... */ />
<YAxis /* ... */ />
<Lines /* ... */ />
<Selection
xScale={xScale}
yScale={yScale}
paneBoundary={paneBoundary}
selected={selected}
colors={linesOptions?.colors ?? DEFAULT_COLORS}
series={series}
{...selectionOptions} />
</Fragment>
);
}
export default LineChartBody;
2.5.3. <Selection />
수정
전달받은 옵션을 사용하도록 <Selection />
과 <Dots />
, <Tooltip />
을 수정하자.
// ...
// `SelectionOptions` 타입의 속성들을 모두 prop 으로 받을 수 있도록 지정해주자.type SelectionProps = SelectionOptions & { // ...
};
function Selection({
xScale,
yScale,
paneBoundary,
selected,
colors,
series,
// 이 아래 값들이 `SelectionOptions` 값들
enabled = true, lineColor = "gray", lineWidth = 1, dot: dotOptions, tooltip: tooltipOptions,}: SelectionProps) {
// ...
if (!enabled) { return null;
}
return (
<>
<G>
{lineWidth > 0 && ( <Line
x1={x}
x2={x}
y1={paneBoundary.y1}
y2={paneBoundary.y2}
stroke={lineColor} strokeWidth={lineWidth} />
)}
<Dots
items={selected}
xScale={xScale}
yScale={yScale}
colors={colors}
// `<Dots />` 를 위한 옵션도 전달해주자. {...dotOptions} />
</G>
<Tooltip
x={tooltipX}
y={paneBoundary.y2}
items={selected}
series={series}
colors={colors}
onLayout={onTooltipLayout}
// `<Tooltip />` 를 위한 옵션도 전달해주자. {...tooltipOptions} />
</>
);
}
export default Selection;
// ...
// `SelectionOptions['dots']` 타입의 속성들을 모두 prop 으로 받을 수 있도록 지정해주자.type DotsProps = SelectionOptions["dot"] & { // ...
};
function Dots({
items,
xScale,
yScale,
colors,
// 이 아래 값들이 `SelectionOptions['dots']` 값들
enabled = true, color = (seriesColor) => seriesColor, radius = 2, borderColor = (seriesColor) => getColorWithAlpha(seriesColor, 0.5), borderWidth = 3,}: DotsProps) {
if (!enabled) { return null;
}
return (
<G>
{items.map((item) => (
<Circle
key={item.seriesIndex}
x={xScale(item.date)}
y={yScale(item.value)}
r={radius} fill={ typeof color === "function" ? color(colors[item.seriesIndex % colors.length]) : color }
stroke={ typeof borderColor === "function" ? borderColor(colors[item.seriesIndex % colors.length]) : borderColor } strokeWidth={borderWidth} />
))}
</G>
);
}
export default Dots;
// ...
// `SelectionOptions['tooltip']` 타입의 속성들을 모두 prop 으로 받을 수 있도록 지정해주자.export type TooltipProps = SelectionOptions["tooltip"] & { // ...
};
function Tooltip({
items,
series,
colors,
x = 0,
y = 0,
onLayout: _onLayout,
// 이 아래 값들이 `SelectionOptions['tooltip']` 값들
enabled = true, padding = 10, paddingTop: _paddingTop, paddingLeft: _paddingLeft, paddingRight: _paddingRight, paddingBottom: _paddingBottom, backgroundColor = "white", borderColor = "darkgray", borderWidth, titleFormatter = (items) => dateFormat(items[0].date), titleSize, titleFont, titleWeight, titleColor, itemCircleRadius = 2, itemCircleColor = (seriesColor) => seriesColor, itemNameFormatter = (item, series) => `${series[item.seriesIndex].name ?? `시리즈 ${item.seriesIndex}`}:`, itemNameSize, itemNameFont, itemNameWeight, itemNameColor, itemValueFormatter = (item) => `${item.value}`, itemValueSize, itemValueFont, itemValueWeight = "600", itemValueColor, titleHeight = 14, itemHeight = 14, itemCircleNameGap = 8, itemNameValueGap = 5,}: TooltipProps) {
// ...
const paddingTop = _paddingTop ?? padding; const paddingLeft = _paddingLeft ?? padding; const paddingRight = _paddingRight ?? padding; const paddingBottom = _paddingBottom ?? padding;
// ...
if (!enabled) { return null;
}
return (
<G x={x} y={y} opacity={loaded ? 1 : 0}>
{loaded && (
<Rect
width={state.width + paddingLeft + paddingRight} height={state.height + paddingTop + paddingBottom} fill={backgroundColor} stroke={borderColor} strokeWidth={borderWidth} onLayout={onLayout}
/>
)}
<G x={paddingLeft} y={paddingTop} onLayout={onContentLayout}> <G>
<Text
alignmentBaseline="hanging"
fontSize={titleSize} fontFamily={titleFont} fontWeight={titleWeight} fill={titleColor} >
{titleFormatter(items)} </Text>
</G>
{items.map((item, i) => (
<G key={i} y={titleHeight + i * itemHeight}> <Circle
x={2}
y={5}
r={itemCircleRadius} fill={ typeof itemCircleColor === "function" ? itemCircleColor(colors[item.seriesIndex % colors.length]) : itemCircleColor } />
<Text
dx={itemCircleRadius * 2 + itemCircleNameGap} alignmentBaseline="hanging"
>
<TSpan
fill={ typeof itemNameColor === "function" ? itemNameColor(colors[item.seriesIndex % colors.length]) : itemNameColor } fontSize={itemNameSize} fontFamily={itemNameFont} fontWeight={itemNameWeight} >
{itemNameFormatter(item, series)} </TSpan>
<TSpan
dx={itemNameValueGap} fill={ typeof itemValueColor === "function" ? itemValueColor(colors[item.seriesIndex % colors.length]) : itemValueColor } fontSize={itemValueSize} fontFamily={itemValueFont} fontWeight={itemValueWeight} >
{itemValueFormatter(item)} </TSpan>
</Text>
</G>
))}
</G>
</G>
);
}
export default Tooltip;
이제 하드코딩은 거의 다 없어졌고, 옵션 값을 사용해 선택된 아이템의 세로선, 점, 툴팁의 스타일을 지정할 수 있게 되었다.
<LineChart
series={dummySeries}
width="100%"
height={200}
xAxisOptions={{ showGridLines: true }}
selectionOptions={{
tooltip: {
enabled: false,
},
}}
/>
<LineChart
series={dummySeries}
width="100%"
height={200}
legendOptions={{ enabled: false }}
selectionOptions={{
lineWidth: 0,
dot: {
radius: 3,
borderWidth: 0,
},
tooltip: {
padding: 5,
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderWidth: 2,
itemNameColor: (seriesColor) => seriesColor,
itemNameFormatter: (item, series) => series[item.seriesIndex].name ?? "",
itemNameSize: 14,
itemNameWeight: "100",
titleHeight: 24,
titleSize: 20,
itemValueColor: (seriesColor) => seriesColor,
itemValueFormatter: (item) => item.value.toLocaleString(),
itemValueWeight: "900",
itemValueSize: 14,
itemHeight: 18,
itemCircleNameGap: 0,
itemNameValueGap: 0,
},
}}
/>
3. 결과
이제 차트에서 터치로 아이템을 선택할 수 있게 되었다.
3.1. 이슈
기능 자체는 잘 동작하지만 이슈가 몇 개 남아있다.
- 툴팁 초기화가 살짝 느리다. 컨텐츠 영역의 크기를
onLayout
콜백으로 측정하고 그 뒤에 배경 및 테두리까지 그린 뒤에야 보여주기 때문이다. - 2.4. 에서 간접적으로 언급했지만, 선택을 취소하는 UX 가 마음에 들지 않는다. 그런데 다른 대안도 썩 마음에 들지는 않아서 문제다.
3.2. 상세 코드 전문
본문에 삽입된 코드들은 생략된 부분들이 있으므로 코드 전문을 보고 싶다면 아래 링크를 참고하자.
3.3. 실행해보기
구현 결과를 실기기 혹은 에뮬레이터에서 직접 확인하고 싶다면 https://github.com/ricale/D3ChartExamples 에서 프로젝트를 받아서 실행해보면 된다.
# 프로젝트 코드 다운로드
git clone https://github.com/ricale/D3ChartExamples.git
cd ./D3ChartExamples
# 안드로이드 실행
yarn
yarn android
# iOS 실행
yarn
cd ./ios && pod install && cd ../
yarn ios
4. 다음
다음에 구현 및 정리할 예정인 작업들은 아래와 같다.
- 라인차트 애니메이션 기능 구현 (초기화 시, 값 변경 시 등)
- 컬럼차트 구현 (상세 계획 미정)
- 파이차트 구현 (상세 계획 미정)