From c6fca137839595e025a8b863ab5ab2a3c7ee8ba8 Mon Sep 17 00:00:00 2001 From: Janghun Lee <74360958+bh2980@users.noreply.github.com> Date: Mon, 27 May 2024 14:32:10 +0900 Subject: [PATCH] =?UTF-8?q?BarChart=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= =?UTF-8?q?=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor(component): width, height 속성 필수로 설정 및 각 변에 축을 그리도록 설정 * 💄 style(css): label을 height 중앙에 오도록 수정, textAlign 삭제 및 tickAlign 추가 * 📝 docs(story): bandAxis Story 수정 * 🚚 chore(component): 주석 추가 * 🚚 chore(component): barChart에서 Axis 제거 및 animate 제거 * ♻️ refactor(component): bar 제거 및 방향 추가 * 💄 style(constant): orient를 BarChart와 동일하게 수정 및 불필요한 속성 css로 이동 * ✨ feat(component): label 및 showLabel props 추가 * 💄 style(css): labelOffset 및 style 조정 * 🚚 chore(type): barProps 삭제 * 📝 docs(story): padding Story 추가 --- .../charts/BandAxis/BandAxis.stories.tsx | 34 ++---- .../charts/BandAxis/BandAxis.styles.ts | 22 +++- src/components/charts/BandAxis/BandAxis.tsx | 55 ++++++--- .../charts/BandAxis/BandAxis.types.ts | 4 +- .../BandAxis/__test__/BandAxis.test.tsx | 46 +++---- .../charts/BarChart/BarChart.stories.tsx | 27 +++- .../charts/BarChart/BarChart.styles.ts | 17 ++- src/components/charts/BarChart/BarChart.tsx | 115 ++++++++---------- .../charts/BarChart/BarChart.types.ts | 17 +-- .../__test__/getAxisOrientConfig.test.ts | 28 ----- src/utils/getAxisOrientConfig.ts | 19 --- 11 files changed, 180 insertions(+), 204 deletions(-) delete mode 100644 src/utils/__test__/getAxisOrientConfig.test.ts delete mode 100644 src/utils/getAxisOrientConfig.ts diff --git a/src/components/charts/BandAxis/BandAxis.stories.tsx b/src/components/charts/BandAxis/BandAxis.stories.tsx index 9f12518..21d6961 100644 --- a/src/components/charts/BandAxis/BandAxis.stories.tsx +++ b/src/components/charts/BandAxis/BandAxis.stories.tsx @@ -29,38 +29,24 @@ const xScale = scaleBand() .range([0, length]); export const Default: Story = { - render: () => ( - - - - ), + render: () => , }; -export const Direction: Story = { +export const Orient: Story = { render: () => ( - - - - - - - - +
+ + + + +
), }; export const LabelHide: Story = { - render: () => ( - - - - ), + render: () => , }; export const LineHide: Story = { - render: () => ( - - - - ), + render: () => , }; diff --git a/src/components/charts/BandAxis/BandAxis.styles.ts b/src/components/charts/BandAxis/BandAxis.styles.ts index aed8f3b..6d2e7c2 100644 --- a/src/components/charts/BandAxis/BandAxis.styles.ts +++ b/src/components/charts/BandAxis/BandAxis.styles.ts @@ -1,5 +1,25 @@ import { tv } from "@utils/customTV"; export const BandAxisVariants = tv({ - base: "stroke-black", + slots: { + root: "stroke-surface-on", + axisLine: "fill-none", + labelText: "stroke-none", + }, + variants: { + lineHide: { + true: { + axisLine: "hidden", + }, + }, + labelHide: { + true: { + labelText: "hidden", + }, + }, + }, + defaultVariants: { + lineHide: false, + labelHide: false, + }, }); diff --git a/src/components/charts/BandAxis/BandAxis.tsx b/src/components/charts/BandAxis/BandAxis.tsx index 671d045..34f6aba 100644 --- a/src/components/charts/BandAxis/BandAxis.tsx +++ b/src/components/charts/BandAxis/BandAxis.tsx @@ -1,10 +1,13 @@ -import { getAxisOrientConfig } from "@utils/getAxisOrientConfig"; import { isEven } from "@utils/isEven"; import { BandAxisVariants } from "./BandAxis.styles"; import { BandAxisProps } from "./BandAxis.types"; +// TODO width, height가 없는 반응형 대응이 필요 +// TODO 조립형으로 하면 각 컴포넌트 별로 props 분리 및 label 회전, 포맷팅도 가능할 듯 -> 꼭 필요한가? 싶긴 함 const BandAxis = ({ - orient = "DOWN", + width, + height, + orient = "UP", axisScale, outerTickLength = 6, innerTickLength = 6, @@ -13,6 +16,8 @@ const BandAxis = ({ className, ...props }: BandAxisProps) => { + const { root, axisLine, labelText } = BandAxisVariants(); + const [startPoint, endPoint] = axisScale.range(); const tickCount = axisScale.domain().length; const isVertical = orient === "LEFT" || orient === "RIGHT"; @@ -21,11 +26,23 @@ const BandAxis = ({ (endPoint - startPoint) / 2 - axisScale.step() * (isEven(tickCount) ? tickCount / 2 - 0.5 : Math.floor(tickCount / 2)); - const [textdX, textdY, path] = getAxisOrientConfig({ orient, startPoint, endPoint, outerTickLength }); + const pathConfig: { [key: string]: string } = { + UP: `M${startPoint + 0.5},${outerTickLength}V0H${endPoint - 0.5}V${outerTickLength}`, + DOWN: `M${startPoint + 0.5},${height - outerTickLength}V${height}H${endPoint - 0.5}V${height - outerTickLength}`, + RIGHT: `M${outerTickLength},${startPoint + 0.5}H0V${endPoint - 0.5}H${outerTickLength}`, + LEFT: `M${width - outerTickLength},${startPoint + 0.5}H${width}V${endPoint - 0.5}H${width - outerTickLength}`, + }; + + const tickAlign = { + UP: `translate(0, 0)`, + DOWN: `translate(0, ${height - innerTickLength})`, + RIGHT: `translate(0, 0)`, + LEFT: `translate(${width - innerTickLength}, 0)`, + }; return ( - - {!lineHide && } + + {axisScale.domain().map((label, i) => ( - {!lineHide && ( - - )} - {!labelHide && ( - - {label} - - )} + + + {label} + ))} - + ); }; diff --git a/src/components/charts/BandAxis/BandAxis.types.ts b/src/components/charts/BandAxis/BandAxis.types.ts index c0ca66b..aa76585 100644 --- a/src/components/charts/BandAxis/BandAxis.types.ts +++ b/src/components/charts/BandAxis/BandAxis.types.ts @@ -7,6 +7,8 @@ export type AxisOrient = "UP" | "DOWN" | "RIGHT" | "LEFT"; type BandAxisBaseProps = { axisScale: ScaleBand; + width: number; + height: number; outerTickLength?: number; innerTickLength?: number; orient?: AxisOrient; @@ -14,6 +16,6 @@ type BandAxisBaseProps = { lineHide?: boolean; }; -export type BandAxisProps = Omit, "textAnchor"> & +export type BandAxisProps = Omit, "width" | "height" | "textAnchor"> & VariantProps & BandAxisBaseProps; diff --git a/src/components/charts/BandAxis/__test__/BandAxis.test.tsx b/src/components/charts/BandAxis/__test__/BandAxis.test.tsx index d97011c..06d367f 100644 --- a/src/components/charts/BandAxis/__test__/BandAxis.test.tsx +++ b/src/components/charts/BandAxis/__test__/BandAxis.test.tsx @@ -21,60 +21,46 @@ describe("BandAxis", () => { }; it("에러 없이 렌더링", () => { - render(); + render(); }); it("data에 맞는 tick 개수", () => { - const { container } = render(); - const ticks = container.querySelectorAll("g > g"); + const { container } = render(); + const ticks = container.querySelectorAll("svg > g"); expect(ticks.length).toBe(4); }); - it("lineHide일 때 path, line 태그 없음", () => { - const { container } = render(); + it("lineHide일 때 path, line 태그 hidden", () => { + const { container } = render(); const path = container.querySelector("path"); const line = container.querySelector("line"); - expect(path).toBe(null); - expect(line).toBe(null); + expect(path).toHaveClass("hidden"); + expect(line).toHaveClass("hidden"); }); it("labelHide일 때 text 태그 없음", () => { - const { container } = render(); - const texts = container.querySelectorAll("text"); - expect(texts.length).toBe(0); - }); - - it("orient가 UP | DOWN 일 경우, transform 속성", () => { - const regex = /^translate\([\d.]+,\s*0\)$/; - ["UP", "DOWN"].forEach((orient) => { - const { container } = render(); - const tick = container.querySelector("g > g"); - expect(tick).toHaveAttribute("transform", expect.stringMatching(regex)); - }); + const { container } = render(); + const text = container.querySelector("text"); + expect(text).toHaveClass("hidden"); }); it("orient가 UP | DOWN 일 경우, line의 y2 존재 및 x2 속성 없음", () => { ["UP", "DOWN"].forEach((orient) => { - const { container } = render(); + const { container } = render( + , + ); const line = container.querySelector("line"); expect(line).toHaveAttribute("y2"); expect(line).not.toHaveAttribute("x2"); }); }); - it("orient가 LEFT | RIGHT 일 경우, transform 속성", () => { - const regex = /^translate\(0,\s*[\d.]+\)$/; - ["LEFT", "RIGHT"].forEach((orient) => { - const { container } = render(); - const tick = container.querySelector("g > g"); - expect(tick).toHaveAttribute("transform", expect.stringMatching(regex)); - }); - }); - it("orient가 LEFT | RIGHT 일 경우, line의 x2 존재 및 y2 속성 없음", () => { ["LEFT", "RIGHT"].forEach((orient) => { - const { container } = render(); + const { container } = render( + , + ); const line = container.querySelector("line"); expect(line).toHaveAttribute("x2"); expect(line).not.toHaveAttribute("y2"); diff --git a/src/components/charts/BarChart/BarChart.stories.tsx b/src/components/charts/BarChart/BarChart.stories.tsx index d141c01..e3abd07 100644 --- a/src/components/charts/BarChart/BarChart.stories.tsx +++ b/src/components/charts/BarChart/BarChart.stories.tsx @@ -22,5 +22,30 @@ const data = [ ]; export const Default: Story = { - render: () => , + render: () => , +}; + +export const Orient: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const ShowLabel: Story = { + render: () => , +}; + +export const Padding: Story = { + render: () => ( +
+ + + +
+ ), }; diff --git a/src/components/charts/BarChart/BarChart.styles.ts b/src/components/charts/BarChart/BarChart.styles.ts index f4ffd33..b3986c7 100644 --- a/src/components/charts/BarChart/BarChart.styles.ts +++ b/src/components/charts/BarChart/BarChart.styles.ts @@ -2,7 +2,20 @@ import { tv } from "@utils/customTV"; export const barChartVariants = tv({ slots: { - bar: "stroke-secondary fill-secondary font-bold text-sm", - xAxis: "text-sm fill-surface-on-variant", + bar: "", + labelText: "", + }, + variants: { + value: { + true: { bar: "fill-secondary" }, + false: { bar: "stroke-secondary fill-none" }, + }, + showLabel: { + false: { labelText: "hidden" }, + }, + }, + defaultVariants: { + value: true, + showLabel: false, }, }); diff --git a/src/components/charts/BarChart/BarChart.tsx b/src/components/charts/BarChart/BarChart.tsx index b79ba1c..d651039 100644 --- a/src/components/charts/BarChart/BarChart.tsx +++ b/src/components/charts/BarChart/BarChart.tsx @@ -1,69 +1,33 @@ import { max, scaleBand, scaleLinear } from "d3"; -import BandAxis from "@charts/BandAxis"; import { barChartVariants } from "./BarChart.styles"; -import type { BarChartProps, BarProps } from "./BarChart.types"; +import type { BarChartProps } from "./BarChart.types"; -//TODO useBar를 통한 속성 제어가 필요할지도? -//useBar가 있으면 nullBarHeight를 별도로 제공해줄 필요가 없을수도? -const Bar = ({ - xScale, - yScale, +// TODO 반응형 대응 +// TODO 애니메이션 설정 +// TODO label을 잘리지 않게 하기 위한 labelOffset 설정, label transform, formatting 등 커스텀 설정 필요 +const BarChart = ({ + width, + height, data, - nullBarHeight = 0, - animationDuration = "0.3s", - rx, - labelPostfix = "", - ...props -}: BarProps) => { - const rectWidth = xScale.bandwidth(); - const rectHeight = yScale(0) - yScale(data.value || nullBarHeight); - const rectX = xScale(data.label.toString())!; - const rectY = yScale(data.value || nullBarHeight); + orient = "UP", + padding = 0.5, + showLabel, + labelOffset = 24, +}: BarChartProps) => { + const isVertical = orient === "UP" || orient === "DOWN"; - const labelOffset = 12; + const labelRange = isVertical ? width : height; + const valueRange = isVertical ? height - labelOffset : width - labelOffset; - return ( - - - - - - - {data.value ? `${data.value}${labelPostfix}` : "?"} - - - - ); -}; - -// TODO useBar와 useAxis를 이용하면 줄일 수 있을 지도? -const BarChart = ({ width, height, data, padding = 0.5 }: BarChartProps) => { - const margin = { x: 0, y: 32 }; - - const xScale = scaleBand() + const labelScale = scaleBand() .domain(data.map((d) => d.label.toString())) - .range([margin.x, width - margin.x]) + .range([0, labelRange]) .padding(padding); - const yScale = scaleLinear() + const valueScale = scaleLinear() .domain([0, max(data, (d) => (d.value ? d.value : 0))!]) .nice() - .range([height - margin.y, margin.y]); + .range([0, valueRange]); const nullBarHeight = data.reduce((acc, cur) => { @@ -71,25 +35,42 @@ const BarChart = ({ width, height, data, padding = 0.5 }: BarChartProps) => { return acc; }, 0) / data.length; - const { bar, xAxis } = barChartVariants(); + const { bar, labelText } = barChartVariants(); return ( {data.map((data, i) => { + const rectWidth = isVertical ? labelScale.bandwidth() : valueScale(data.value || nullBarHeight); + const rectHeight = isVertical ? valueScale(data.value || nullBarHeight) : labelScale.bandwidth(); + const rectX = isVertical ? labelScale(data.label.toString())! : orient === "LEFT" ? 0 : width - rectWidth; + const rectY = isVertical ? (orient === "UP" ? height - rectHeight : 0) : labelScale(data.label.toString())!; + + const labelAlign = { + UP: [rectX + rectWidth / 2, rectY - 16], + DOWN: [rectX + rectWidth / 2, rectHeight + 16], + LEFT: [rectWidth + 28, rectY + rectHeight / 2], + RIGHT: [rectX - 28, rectY + rectHeight / 2], + }; + + const [labelX, labelY] = labelAlign[orient]; + return ( - + + + + {data.value ? `${data.value}` : "?"} + + ); })} - ); }; diff --git a/src/components/charts/BarChart/BarChart.types.ts b/src/components/charts/BarChart/BarChart.types.ts index 951f783..dfa163c 100644 --- a/src/components/charts/BarChart/BarChart.types.ts +++ b/src/components/charts/BarChart/BarChart.types.ts @@ -1,22 +1,13 @@ -import type { ScaleBand, ScaleLinear } from "d3"; -import type { PropsWithChildren } from "react"; -import type { PolymorphicPropsType } from "@customTypes/polymorphicType"; +import type { AxisOrient } from "@charts/BandAxis"; type BarChartDataType = { label: number; value: number | null }; export type BarChartProps = { + orient?: AxisOrient; width: number; height: number; data: BarChartDataType[]; padding?: number; + showLabel?: boolean; + labelOffset?: number; }; - -export type BarProps = PolymorphicPropsType<"g"> & - PropsWithChildren & { - xScale: ScaleBand; - yScale: ScaleLinear; - data: BarChartDataType; - nullBarHeight?: number; - animationDuration?: string; - labelPostfix?: string; - }; diff --git a/src/utils/__test__/getAxisOrientConfig.test.ts b/src/utils/__test__/getAxisOrientConfig.test.ts deleted file mode 100644 index 4fdc527..0000000 --- a/src/utils/__test__/getAxisOrientConfig.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { getAxisOrientConfig } from "@utils/getAxisOrientConfig"; - -describe("getAxisOrientConfig", () => { - const startPoint = 0; - const endPoint = 100; - const outerTickLength = 10; - - test("returns correct config for UP orientation", () => { - const result = getAxisOrientConfig({ orient: "UP", startPoint, endPoint, outerTickLength }); - expect(result).toEqual([0, -6, "M0.5,0V10H99.5V0"]); - }); - - test("returns correct config for DOWN orientation", () => { - const result = getAxisOrientConfig({ orient: "DOWN", startPoint, endPoint, outerTickLength }); - expect(result).toEqual([0, 24, "M0.5,10V0H99.5V10"]); - }); - - test("returns correct config for LEFT orientation", () => { - const result = getAxisOrientConfig({ orient: "LEFT", startPoint, endPoint, outerTickLength }); - expect(result).toEqual([-24, 6, "M0,0.5H10V99.5H0"]); - }); - - test("returns correct config for RIGHT orientation", () => { - const result = getAxisOrientConfig({ orient: "RIGHT", startPoint, endPoint, outerTickLength }); - expect(result).toEqual([28, 6, "M10,0.5H0V99.5H10"]); - }); -}); diff --git a/src/utils/getAxisOrientConfig.ts b/src/utils/getAxisOrientConfig.ts deleted file mode 100644 index d299e79..0000000 --- a/src/utils/getAxisOrientConfig.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AxisOrient } from "@charts/BandAxis/BandAxis.types"; - -type getAxisOrientConfigParams = { - orient: AxisOrient; - startPoint: number; - endPoint: number; - outerTickLength: number; -}; - -export const getAxisOrientConfig = ({ orient, startPoint, endPoint, outerTickLength }: getAxisOrientConfigParams) => { - const pathConfig: { [key: string]: [number, number, string] } = { - UP: [0, -6, `M${startPoint + 0.5},0V${outerTickLength}H${endPoint - 0.5}V0`], - DOWN: [0, 24, `M${startPoint + 0.5},${outerTickLength}V0H${endPoint - 0.5}V${outerTickLength}`], - LEFT: [-24, 6, `M0,${startPoint + 0.5}H${outerTickLength}V${endPoint - 0.5}H0`], - RIGHT: [28, 6, `M${outerTickLength},${startPoint + 0.5}H0V${endPoint - 0.5}H${outerTickLength}`], - }; - - return pathConfig[orient]; -};