Skip to content

Commit

Permalink
Recommend Chart 추가 (#37)
Browse files Browse the repository at this point in the history
* 💄 style(type): barChartDataType에 label을 string으로 수정 및 value에 null 제거, NullableBarChartType 추가

* 📝 docs(story): label을 string으로 수정

* 💄 style(css): light/secondary 색상 변경

* ✨ feat(component): recommendChart 추가

* ✅ test(component): recommendChart 테스트 코드 추가

ISSUES CLOSED: #12
  • Loading branch information
bh2980 authored May 27, 2024
1 parent c6fca13 commit 3679b4f
Show file tree
Hide file tree
Showing 10 changed files with 334 additions and 10 deletions.
10 changes: 5 additions & 5 deletions src/components/charts/BarChart/BarChart.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ export default meta;
type Story = StoryObj<typeof BarChart>;

const data = [
{ label: 2019, value: 151.8 },
{ label: 2020, value: 242.2 },
{ label: 2021, value: 121.3 },
{ label: 2022, value: 200.7 },
{ label: 2023, value: null }, // 값이 아직 없는 경우
{ label: "2019", value: 151.8 },
{ label: "2020", value: 242.2 },
{ label: "2021", value: 121.3 },
{ label: "2022", value: 200.7 },
{ label: "2023", value: null }, // 값이 아직 없는 경우
];

export const Default: Story = {
Expand Down
6 changes: 4 additions & 2 deletions src/components/charts/BarChart/BarChart.types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { AxisOrient } from "@charts/BandAxis";

type BarChartDataType = { label: number; value: number | null };
export type BarChartDataType = { label: string; value: number };

export type NullableBarChartType = BarChartDataType | { label: string; value: number | null };

export type BarChartProps = {
orient?: AxisOrient;
width: number;
height: number;
data: BarChartDataType[];
data: NullableBarChartType[];
padding?: number;
showLabel?: boolean;
labelOffset?: number;
Expand Down
4 changes: 2 additions & 2 deletions src/components/charts/BarChart/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import BarChart from "./BarChart";
import { barChartVariants } from "./BarChart.styles";
import type { BarChartProps } from "./BarChart.types";
import type { BarChartDataType, BarChartProps, NullableBarChartType } from "./BarChart.types";

export type { BarChartProps };
export type { BarChartProps, BarChartDataType, NullableBarChartType };

export { barChartVariants };

Expand Down
85 changes: 85 additions & 0 deletions src/components/charts/RecommendChart/RecommendChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react";
import RecommendChart from "./RecommendChart";

const meta = {
title: "charts/RecommendChart",
component: RecommendChart,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof RecommendChart>;

export default meta;
type Story = StoryObj<typeof RecommendChart>;

const data = [
{ label: "적극 매수", value: 8 },
{ label: "매수", value: 10 },
{ label: "보유", value: 7 },
{ label: "매도", value: 3 },
{ label: "적극 매도", value: 2 },
];

export const Default: Story = {
render: () => <RecommendChart data={data} width={320} height={128} />,
};

const ActiveBuyData = [
{ label: "적극 매수", value: 18 },
{ label: "매수", value: 10 },
{ label: "보유", value: 7 },
{ label: "매도", value: 3 },
{ label: "적극 매도", value: 2 },
];

export const ActiveBuy: Story = {
render: () => <RecommendChart data={ActiveBuyData} width={320} height={128} />,
};

const BuyData = [
{ label: "적극 매수", value: 8 },
{ label: "매수", value: 10 },
{ label: "보유", value: 7 },
{ label: "매도", value: 3 },
{ label: "적극 매도", value: 2 },
];

export const Buy: Story = {
render: () => <RecommendChart data={BuyData} width={320} height={128} />,
};

const HoldData = [
{ label: "적극 매수", value: 8 },
{ label: "매수", value: 10 },
{ label: "보유", value: 17 },
{ label: "매도", value: 3 },
{ label: "적극 매도", value: 2 },
];
export const Hold: Story = {
render: () => <RecommendChart data={HoldData} width={320} height={128} />,
};

const SellData = [
{ label: "적극 매수", value: 8 },
{ label: "매수", value: 10 },
{ label: "보유", value: 7 },
{ label: "매도", value: 13 },
{ label: "적극 매도", value: 2 },
];

export const Sell: Story = {
render: () => <RecommendChart data={SellData} width={320} height={128} />,
};

const ActiveSellData = [
{ label: "적극 매수", value: 8 },
{ label: "매수", value: 10 },
{ label: "보유", value: 7 },
{ label: "매도", value: 3 },
{ label: "적극 매도", value: 12 },
];

export const ActiveSell: Story = {
render: () => <RecommendChart data={ActiveSellData} width={320} height={128} />,
};
72 changes: 72 additions & 0 deletions src/components/charts/RecommendChart/RecommendChart.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { tv } from "@utils/customTV";

export const RecommendChartVariants = tv({
slots: {
root: "flex gap-md",
square: "text-primary-on font-bold rounded-md flex justify-center items-center text-lg",
barChart: "",
barBg: "fill-surface-container-high",
bar: "",
labelText: "text-sm fill-surface-on",
},
variants: {
label: {
"적극 매수": {
square: "bg-primary fill-primary-on",
},
매수: {
square: "bg-secondary fill-secondary-on",
},
보유: {
square: "bg-surface-inverse fill-surface-on-inverse",
},
매도: {
square: "bg-error-container-on fill-error-container",
},
"적극 매도": {
square: "bg-error fill-error-on",
},
},
isMaxValue: {
true: { bar: "fill-secondary" },
false: { bar: "fill-surface-on/20" },
},
},
compoundVariants: [
{
label: "적극 매수",
isMaxValue: true,
class: {
bar: "fill-primary",
},
},
{
label: "매수",
isMaxValue: true,
class: {
bar: "fill-secondary",
},
},
{
label: "보유",
isMaxValue: true,
class: {
bar: "fill-surface-inverse",
},
},
{
label: "매도",
isMaxValue: true,
class: {
bar: "fill-error-container-on",
},
},
{
label: "적극 매도",
isMaxValue: true,
class: {
bar: "fill-error",
},
},
],
});
75 changes: 75 additions & 0 deletions src/components/charts/RecommendChart/RecommendChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { scaleBand, scaleLinear, sum } from "d3";
import BandAxis from "@charts/BandAxis";
import { RecommendChartVariants } from "./RecommendChart.styles";
import { RecommendChartProps, RecommendLabelType } from "./RecommendChart.types";

// TODO 반응형 처리 필요
// TODO 나중에 Bar 애니메이션 처리하면서 Bar 컴포넌트 따로 빼서 BarChart랑 Bar 컴포넌트 공유하기
const RecommendChart = ({ width, height, data, className, ...props }: RecommendChartProps) => {
const total = sum(data, (d) => d.value);

const labelScale = scaleBand()
.domain(data.map((d) => d.label.toString()))
.range([0, height])
.padding(0.6);

const bandAxisWidth = 64;
const barChartWidth = width - (bandAxisWidth + height);

const valueScale = scaleLinear().domain([0, total]).range([0, barChartWidth]);

const { label: maxLabel, value: maxValue } = data.reduce(
(acc, cur) => {
if (acc.value <= cur.value) {
acc = cur;
}

return acc;
},
{ label: "", value: -1 },
);

const { root, bar, barBg, barChart, labelText, square } = RecommendChartVariants();

return (
<div className={root({ className })} {...props}>
<svg width={height} height={height} className={square({ label: maxLabel as RecommendLabelType })}>
<text textAnchor="middle" dominantBaseline={"central"} transform={`translate(${height / 2}, ${height / 2})`}>
{maxLabel}
</text>
</svg>
<svg width={barChartWidth} className={barChart()}>
{data.map((d, i) => {
return (
<g key={`bar-${i}`}>
<rect
width={valueScale(total)}
height={labelScale.bandwidth()}
y={labelScale(d.label.toString())}
rx={6}
className={barBg()}
/>
<rect
width={valueScale(d.value)}
height={labelScale.bandwidth()}
y={labelScale(d.label)}
rx={6}
className={bar({ label: maxLabel as RecommendLabelType, isMaxValue: maxValue === d.value })}
/>
</g>
);
})}
</svg>
<BandAxis
axisScale={labelScale}
orient="RIGHT"
width={bandAxisWidth}
height={height}
className={labelText()}
lineHide
/>
</div>
);
};

export default RecommendChart;
16 changes: 16 additions & 0 deletions src/components/charts/RecommendChart/RecommendChart.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { VariantProps } from "tailwind-variants";
import type { ComponentPropsWithInnerRef } from "@customTypes/utilType";
import { BarChartDataType } from "@charts/BarChart";
import { RecommendChartVariants } from "./RecommendChart.styles";

export type RecommendLabelType = "적극 매수" | "매수" | "보유" | "매도" | "적극 매도";

type RecommendChartBaseProps = {
data: BarChartDataType[];
width: number;
height: number;
};

export type RecommendChartProps = ComponentPropsWithInnerRef<"div"> &
VariantProps<typeof RecommendChartVariants> &
RecommendChartBaseProps;
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* eslint-disable testing-library/no-node-access */

/* eslint-disable testing-library/no-container */
import { composeStories } from "@storybook/react";
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import * as stories from "../RecommendChart.stories";

describe("recommend chart", () => {
const { Default, ActiveBuy, ActiveSell, Buy, Hold, Sell } = composeStories(stories);

it("에러 없이 렌더링", () => {
render(<Default />);
});

it("10개의 rect 렌더링", () => {
const { container } = render(<Default />);

const bars = container.querySelectorAll("rect");

expect(bars.length).toBe(10);
});

it("적극 매수 데이터에서 적극 매수 텍스트를 보여줘야합니다.", () => {
const { container } = render(<ActiveBuy />);

const squareLabel = container.querySelector("svg > text");

expect(squareLabel).toHaveTextContent("적극 매수");
});

it("매수 데이터에서 매수 텍스트를 보여줘야합니다.", () => {
const { container } = render(<Buy />);

const squareLabel = container.querySelector("svg > text");

expect(squareLabel).toHaveTextContent("매수");
expect(squareLabel).not.toHaveTextContent("적극 매수");
});

it("보유 데이터에서 보유 텍스트를 보여줘야합니다.", () => {
const { container } = render(<Hold />);

const squareLabel = container.querySelector("svg > text");

expect(squareLabel).toHaveTextContent("보유");
});

it("매도 데이터에서 매도 텍스트를 보여줘야합니다.", () => {
const { container } = render(<Sell />);

const squareLabel = container.querySelector("svg > text");

expect(squareLabel).toHaveTextContent("매도");
expect(squareLabel).not.toHaveTextContent("적극 매도");
});

it("적극 매도 데이터에서 적극 매도 텍스트를 보여줘야합니다.", () => {
const { container } = render(<ActiveSell />);

const squareLabel = container.querySelector("svg > text");

expect(squareLabel).toHaveTextContent("적극 매도");
});
});
9 changes: 9 additions & 0 deletions src/components/charts/RecommendChart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import RecommendChart from "./RecommendChart";
import { RecommendChartVariants } from "./RecommendChart.styles";
import type { RecommendChartProps, RecommendLabelType } from "./RecommendChart.types";

export type { RecommendChartProps, RecommendLabelType };

export { RecommendChartVariants };

export default RecommendChart;
2 changes: 1 addition & 1 deletion tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export default {
},
},
secondary: {
DEFAULT: color.secondary[20],
DEFAULT: color.secondary[40],
on: color.secondary[100],
container: {
DEFAULT: color.secondary[90],
Expand Down

0 comments on commit 3679b4f

Please sign in to comment.