diff --git a/frontend/src/components/shared/bar-chart/Bar.tsx b/frontend/src/components/shared/bar-chart/Bar.tsx index 482508e06..df7e87efd 100644 --- a/frontend/src/components/shared/bar-chart/Bar.tsx +++ b/frontend/src/components/shared/bar-chart/Bar.tsx @@ -26,6 +26,7 @@ interface BarProps { isActive?: boolean; //포커스거나 강조해야 하는 상태인지 bgColor?: string; barColorChangeOnHover?: boolean; // 바에 호버 시 색상 변화 줄지 말지 + className?: string; } export const Bar = ({ @@ -42,6 +43,7 @@ export const Bar = ({ bgColor = BAR_CHART.DEFAULT_BAR_COLOR, isActive = false, barColorChangeOnHover = true, + className, }: BarProps) => { const pathRef = useRef(null); const barRef = useRef(null); // g 태그 조작(막대 위치 이동시 애니메이션)을 위한 ref @@ -83,6 +85,7 @@ export const Bar = ({ style={{ transformOrigin: `${barMiddleX}px ${barTopY + height}px`, }} + className={className} > {activeTooltip ? ( diff --git a/frontend/src/components/shared/bar-chart/BarChart.tsx b/frontend/src/components/shared/bar-chart/BarChart.tsx index f11d7655d..06fee57ab 100644 --- a/frontend/src/components/shared/bar-chart/BarChart.tsx +++ b/frontend/src/components/shared/bar-chart/BarChart.tsx @@ -1,14 +1,10 @@ -import { - XAxis, - XAxisLabel, - XGuideLine, - YGuideLine, -} from '@/components/shared/line-chart'; import { useBarChart } from '@/hooks/shared'; import { useBarChartId } from '@/hooks/shared'; import type { XAxisType } from '@/types/shared'; import type { AllBarChartSeries } from '@/types/shared'; +import { XAxis, XAxisLabel, XGuideLine, YGuideLine } from '../chart'; + import { BarSeries } from './BarSeries'; /** * @description 막대 차트 컴포넌트 (자세한 사용법은 스토리북 문서 참고) diff --git a/frontend/src/components/shared/bar-chart/BarSeries.tsx b/frontend/src/components/shared/bar-chart/BarSeries.tsx index c8b3a4213..b1e81c5f3 100644 --- a/frontend/src/components/shared/bar-chart/BarSeries.tsx +++ b/frontend/src/components/shared/bar-chart/BarSeries.tsx @@ -6,7 +6,12 @@ import type { StackBarDatum, } from '@/types/shared'; import type { AllBarChartSeries } from '@/types/shared'; -import { checkIsStackBarChart } from '@/utils/shared'; +import { + checkIsStackBarChart, + getBarHeight, + getBarWidth, + getLabelContentText, +} from '@/utils/shared'; import { Bar } from './Bar'; import { BarLabel } from './BarLabel'; @@ -44,70 +49,20 @@ export const BarSeries = ({ activeDataIndex, barColorChangeOnHover, }: BarSeriesProps) => { - const { XAXIS_Y_OFFSET, XAXIS_STROKE_WIDTH, BAR_RADIUS } = BAR_CHART; // X축이 있을 때 X축의 Y좌표 오프셋 값 + const { BAR_RADIUS } = BAR_CHART; // X축이 있을 때 X축의 Y좌표 오프셋 값 // 스택바 그래프인지 일반 바 그래프인지 -> mainY의 값이 배열이면 스택바 const isStackBar = checkIsStackBarChart({ series }); - // 바 전체 높이 계산 (바의 상단 y좌표 부터 x축 또는 svg 하단까지의 거리) - const getBarHeight = ({ - y, - hasXAxis, - viewBoxHeight, - }: { - y: number; - hasXAxis: boolean; - viewBoxHeight: number; - }) => { - if (hasXAxis) { - // x축이 있을 때는 x축의 y위치 만큼을 빼고 축 높이의 0.5배 만큼 더 빼줘야 함 - return viewBoxHeight - XAXIS_Y_OFFSET - y - XAXIS_STROKE_WIDTH / 2; // x축이 있을 때 바 높이는 y좌표 ~ x 축까지 거리 - } - return viewBoxHeight - y; // x축이 없을 떄 바 높이는 y좌표 ~ svg 최하단 까지 거리 - }; - - // 바 너비는 막대 간격의 50%로 설정 (막대 간격은 viewBoxWidth / x축의 지점 개수) - const getBarWidth = ({ - viewBoxWidth, - xCoordinate, - }: { - viewBoxWidth: number; - xCoordinate: Coordinate[]; - }) => { - return (viewBoxWidth / xCoordinate.length) * 0.5; - }; - // 바 위에 표시될 라벨 내용 텍스트 생성 - const getLabelContentText = ({ - index, - series, - }: { - index: number; - series: AllBarChartSeries; - }) => { - if (isStackBar) { - // 스택바 그래프일 때는 mainY의 각 항목이 배열이므로 각 스택의 합계를 계산하여 라벨에 표시 - const stackValues = series.data.mainY[index] as StackBarDatum; - const total = stackValues.reduce((sum, item) => { - if (typeof item.amount === 'number') { - return sum + item.amount; - } - return sum; - }, 0); - const unit = stackValues[0]?.unit || ''; // 단위는 첫 번째 항목의 단위를 사용 - return `${total} ${unit}`; - } else { - // 일반 바 그래프일 때는 mainY의 단일 값을 라벨에 표시 - const value = series.data.mainY[index] as BarChartDatum; - return `${value.amount} ${value.unit}`; - } - }; - return ( <> {coordinate.map(({ x, y }, index) => { if (x !== null && y !== null) { const barHeight = getBarHeight({ y, hasXAxis, viewBoxHeight }); - const barWidth = getBarWidth({ viewBoxWidth, xCoordinate }); // 막대 너비는 막대 간격의 50% + const barWidth = getBarWidth({ + viewBoxWidth, + xDataLength: xCoordinate.length, + }); // 막대 너비는 막대 간격의 50% // 막대 그래프 툴팁에 넣을 내용 const tooltipContentText = tooltipContent ? tooltipContent( @@ -126,7 +81,7 @@ export const BarSeries = ({ )} diff --git a/frontend/src/components/shared/bar-chart/index.ts b/frontend/src/components/shared/bar-chart/index.ts index fdfc3f3ab..07c18b76a 100644 --- a/frontend/src/components/shared/bar-chart/index.ts +++ b/frontend/src/components/shared/bar-chart/index.ts @@ -1 +1,2 @@ export { BarChart } from './BarChart'; +export { Bar } from './Bar'; diff --git a/frontend/src/components/shared/bar-line-chart/BarLineChart.stories.tsx b/frontend/src/components/shared/bar-line-chart/BarLineChart.stories.tsx new file mode 100644 index 000000000..8e65035de --- /dev/null +++ b/frontend/src/components/shared/bar-line-chart/BarLineChart.stories.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Button, TooltipProvider } from '@/components/shared/shadcn-ui'; +import { + BAR_LINE_MONTHLY_MOCK, + BAR_LINE_REALTIME_MOCK, + BAR_LINE_WEEKLY_MOCK, +} from '@/mocks/data'; +import type { BarLineChartSeries } from '@/types/shared'; + +import { BarLineChart } from './BarLineChart'; + +const meta = { + title: 'components/shared/bar-line-chart/BarLineChart', + component: BarLineChart, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: {}, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const RealtimeBarLineChart = (args: Story['args']) => { + const [barLineChartSeries, setBarLineChartSeries] = + useState(args.barLineChartSeries as BarLineChartSeries); + + const handleUpdateCurrentSeries = () => { + let currentIndex = + barLineChartSeries.data.mainY.filter((datum) => datum.amount !== null) + .length - 1; + + if (currentIndex < 0) { + currentIndex = 0; + } + + setBarLineChartSeries((prev) => { + const newMainY = [...prev.data.mainY]; + const newSubY = [...prev.data.subY]; + + const currentMainYAmount = Number(newMainY[currentIndex]?.amount ?? 0); + const currentSubYAmount = Number(newSubY[currentIndex]?.amount ?? 0); + + newMainY[currentIndex] = { + ...newMainY[currentIndex], + amount: +(currentMainYAmount + Math.random() * 2).toFixed(1), + unit: '만', + }; + + newSubY[currentIndex] = { + ...newSubY[currentIndex], + amount: currentSubYAmount + Math.floor(Math.random() * 3 + 1), + unit: '건', + }; + + return { + ...prev, + data: { + ...prev.data, + mainY: newMainY, + subY: newSubY, + }, + }; + }); + }; + + const handleUpdateNextSeries = () => { + const nextIndex = barLineChartSeries.data.mainY.filter( + (datum) => datum.amount !== null, + ).length; + + if (nextIndex >= barLineChartSeries.data.mainY.length) { + return; + } + + setBarLineChartSeries((prev) => ({ + ...prev, + data: { + ...prev.data, + mainY: [ + ...prev.data.mainY.slice(0, nextIndex), + { amount: 0, unit: '만' }, + ...prev.data.mainY.slice(nextIndex + 1), + ], + subY: [ + ...prev.data.subY.slice(0, nextIndex), + { amount: 0, unit: '건' }, + ...prev.data.subY.slice(nextIndex + 1), + ], + }, + })); + }; + + const handleReset = () => { + setBarLineChartSeries({ + ...BAR_LINE_REALTIME_MOCK, + data: { + ...BAR_LINE_REALTIME_MOCK.data, + mainY: BAR_LINE_REALTIME_MOCK.data.mainY.map((datum) => ({ + ...datum, + amount: null, + })), + subY: BAR_LINE_REALTIME_MOCK.data.subY.map((datum) => ({ + ...datum, + amount: null, + })), + }, + }); + }; + + return ( +
+
+ +
+ + + +
+ ); +}; + +export const Default: Story = { + args: { + viewBoxWidth: 1000, + viewBoxHeight: 300, + yGuideLineCount: 5, + xAxisType: 'default', + chartTitle: 'BarLineChart', + chartDescription: 'BarLineChart', + hasXAxis: true, + showXGuideLine: true, + showYGuideLine: true, + tooltipContent: (mainY, subY) => `${mainY}/${subY}`, + barLineChartSeries: BAR_LINE_WEEKLY_MOCK, + }, + render: (args) => ( + +
+ +
+
+ ), +}; + +export const Monthly30Days: Story = { + args: { + viewBoxWidth: 2000, + viewBoxHeight: 400, + yGuideLineCount: 4, + xAxisType: 'right-arrow', + chartTitle: '일별 매출 추이', + chartDescription: '최근 30일 실매출과 주문건수 데이터', + hasXAxis: true, + showXGuideLine: false, + showYGuideLine: true, + activeTooltip: true, + tooltipContent: (mainY, subY) => `${mainY}/${subY}`, + barLineChartSeries: BAR_LINE_MONTHLY_MOCK, + }, + render: (args) => ( + +
+ +
+
+ ), +}; + +export const Realtime: Story = { + args: { + viewBoxWidth: 1020, + viewBoxHeight: 260, + yGuideLineCount: 4, + xAxisType: 'tick', + chartTitle: '시간대별 실시간 매출/주문', + chartDescription: '실시간으로 업데이트되는 바-라인 차트', + hasXAxis: true, + showXGuideLine: true, + showYGuideLine: true, + activeTooltip: true, + tooltipContent: (mainY, subY) => `${mainY}/${subY}`, + barLineChartSeries: BAR_LINE_REALTIME_MOCK, + }, + render: (args) => ( + + + + ), +}; diff --git a/frontend/src/components/shared/bar-line-chart/BarLineChart.tsx b/frontend/src/components/shared/bar-line-chart/BarLineChart.tsx new file mode 100644 index 000000000..962e473f0 --- /dev/null +++ b/frontend/src/components/shared/bar-line-chart/BarLineChart.tsx @@ -0,0 +1,175 @@ +import { useBarLineChart, useBarLineChartId } from '@/hooks/shared'; +import type { BarLineChartSeries, XAxisType } from '@/types/shared'; + +import { XAxis, XAxisLabel, XGuideLine, YGuideLine } from '../chart'; +import { Line, LineChartGradient } from '../line-chart'; + +import { BarLineSeriesRenderer } from './BarLineSeriesRenderer'; + +interface BarLineChartProps { + /** + * 바 라인 차트의 너비 + */ + viewBoxWidth: number; + /** + * 바 라인 차트의 높이 + */ + viewBoxHeight: number; + /** + * X축과 X축 레이블 표시 여부 + */ + hasXAxis?: boolean; + /** + * X축 가이드 라인 표시 여부 (X축과 수직으로 표시되는 점선, 개수는 x축 데이터와 동일) + */ + showXGuideLine?: boolean; + /** + * Y축 가이드 라인 표시 여부 (Y축과 수평으로 표시되는 점선) + */ + showYGuideLine?: boolean; + /** + * Y축 가이드 라인 개수 (Y축과 수평으로 표시되는 점선의 개수) + */ + yGuideLineCount: number; + /** + * 각 데이터의 툴팁 표시 여부 + */ + activeTooltip?: boolean; + /** + * 각 데이터의 툴팁 내용 표시 함수 (실시간 데이터와 평균 데이터의 값을 받아 표시 ex: (mainY, subY) => {mainY} {subY})) + */ + tooltipContent?: (...args: string[]) => string; + /** + * X축 타입 (일반, 양쪽 세로선, 오른쪽 화살표) + */ + xAxisType: XAxisType; + /** + * 바 라인 차트 첫 번쩨 데이터 (실시간 데이터 or 단일 데이터) - 차트의 색상은 primarySeries의 color 속성에 따라 자동으로 설정됨 + * mainX: 차트의 X축 데이터 + * subX: 차트의 X축 데이터 (sub) + * mainY: 차트의 Y축 데이터 (bar) + * subY: 차트의 Y축 데이터 (line) + */ + barLineChartSeries: BarLineChartSeries; + /** + * 바 라인 차트의 제목 + */ + chartTitle?: string; + /** + * 바 라인 차트의 설명 + */ + chartDescription?: string; +} + +export const BarLineChart = ({ + viewBoxWidth, + viewBoxHeight, + hasXAxis = false, + showXGuideLine = false, + showYGuideLine = false, + yGuideLineCount, + activeTooltip = false, + tooltipContent = (...args: string[]) => args.join(' '), + xAxisType, + barLineChartSeries, + chartTitle, + chartDescription, +}: BarLineChartProps) => { + const { titleId, descId, lineGradientId } = useBarLineChartId(); + const { + svgRef, + xAxisRef, + svgWidth, + adjustedHeight, + xLabelList, + xCoordinate, + barCoordinate, + lineCoordinate, + } = useBarLineChart({ + viewBoxWidth, + viewBoxHeight, + barLineChartSeries, + hasXAxis, + }); + + const seriesLength = Math.min( + barLineChartSeries.data.mainX.length, + barCoordinate.length, + lineCoordinate.length, + ); + + return ( + + + {chartTitle} + {chartDescription} + + + {showYGuideLine && ( + + )} + {showXGuideLine && ( + + )} + {hasXAxis && ( + <> + + + + )} + + {Array.from({ length: seriesLength }).map((_, index) => { + if ( + barCoordinate[index].y === null || + lineCoordinate[index].y === null + ) { + return null; + } + return ( + + ); + })} + + ); +}; diff --git a/frontend/src/components/shared/bar-line-chart/BarLineSeries.tsx b/frontend/src/components/shared/bar-line-chart/BarLineSeries.tsx new file mode 100644 index 000000000..d7c93eda6 --- /dev/null +++ b/frontend/src/components/shared/bar-line-chart/BarLineSeries.tsx @@ -0,0 +1,59 @@ +import { BAR_CHART } from '@/constants/shared'; + +import { Bar } from '../bar-chart'; +import { Dot } from '../line-chart'; + +interface BarLineSeriesProps { + barX: number; + barY: number; + lineX: number; + lineY: number; + color: string; + tooltipContentText: string; + barWidth: number; + barHeight: number; + interactionPathD: string; +} + +export const BarLineSeries = ({ + barX, + barY, + lineX, + lineY, + color, + tooltipContentText, + barWidth, + barHeight, + interactionPathD, +}: BarLineSeriesProps) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/components/shared/bar-line-chart/BarLineSeriesRenderer.tsx b/frontend/src/components/shared/bar-line-chart/BarLineSeriesRenderer.tsx new file mode 100644 index 000000000..5d97d66b9 --- /dev/null +++ b/frontend/src/components/shared/bar-line-chart/BarLineSeriesRenderer.tsx @@ -0,0 +1,73 @@ +import { useDrawBarLine } from '@/hooks/shared'; +import type { Coordinate } from '@/types/shared'; + +import { BarLineSeries } from './BarLineSeries'; +import { BarLineSeriesWithTooltip } from './BarLineSeriesWithTooltip'; + +interface BarLineSeriesRendererProps { + barX: number; + barY: number; + lineX: number; + lineY: number; + color: string; + tooltipContentText: string; + hasXAxis: boolean; + viewBoxHeight: number; + viewBoxWidth: number; + xCoordinate: Coordinate[]; + activeTooltip: boolean; +} + +export const BarLineSeriesRenderer = ({ + barX, + barY, + lineX, + lineY, + color, + tooltipContentText, + hasXAxis, + viewBoxHeight, + viewBoxWidth, + xCoordinate, + activeTooltip, +}: BarLineSeriesRendererProps) => { + const { barHeight, barWidth, interactionPathD } = useDrawBarLine({ + barX, + barY, + lineY, + hasXAxis, + viewBoxHeight, + viewBoxWidth, + xCoordinate, + }); + + if (!activeTooltip) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/frontend/src/components/shared/bar-line-chart/BarLineSeriesWithTooltip.tsx b/frontend/src/components/shared/bar-line-chart/BarLineSeriesWithTooltip.tsx new file mode 100644 index 000000000..ebf27ba53 --- /dev/null +++ b/frontend/src/components/shared/bar-line-chart/BarLineSeriesWithTooltip.tsx @@ -0,0 +1,76 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/shared/shadcn-ui'; +import { BAR_CHART } from '@/constants/shared'; + +import { Bar } from '../bar-chart'; +import { Dot } from '../line-chart'; + +interface BarLineSeriesWithTooltipProps { + barX: number; + barY: number; + lineX: number; + lineY: number; + color: string; + tooltipContentText: string; + interactionPathD: string; + barWidth: number; + barHeight: number; +} + +export const BarLineSeriesWithTooltip = ({ + barX, + barY, + lineX, + lineY, + color, + tooltipContentText, + interactionPathD, + barWidth, + barHeight, +}: BarLineSeriesWithTooltipProps) => { + return ( + + + + + + + + + +

+ {tooltipContentText} +

+
+
+ ); +}; diff --git a/frontend/src/components/shared/bar-line-chart/index.ts b/frontend/src/components/shared/bar-line-chart/index.ts new file mode 100644 index 000000000..4d2f9ad2f --- /dev/null +++ b/frontend/src/components/shared/bar-line-chart/index.ts @@ -0,0 +1 @@ +export { BarLineChart } from './BarLineChart'; diff --git a/frontend/src/components/shared/line-chart/XAxis.tsx b/frontend/src/components/shared/chart/XAxis.tsx similarity index 100% rename from frontend/src/components/shared/line-chart/XAxis.tsx rename to frontend/src/components/shared/chart/XAxis.tsx diff --git a/frontend/src/components/shared/line-chart/XAxisLabel.tsx b/frontend/src/components/shared/chart/XAxisLabel.tsx similarity index 100% rename from frontend/src/components/shared/line-chart/XAxisLabel.tsx rename to frontend/src/components/shared/chart/XAxisLabel.tsx diff --git a/frontend/src/components/shared/line-chart/XGuideLine.tsx b/frontend/src/components/shared/chart/XGuideLine.tsx similarity index 100% rename from frontend/src/components/shared/line-chart/XGuideLine.tsx rename to frontend/src/components/shared/chart/XGuideLine.tsx diff --git a/frontend/src/components/shared/line-chart/YGuideLine.tsx b/frontend/src/components/shared/chart/YGuideLine.tsx similarity index 100% rename from frontend/src/components/shared/line-chart/YGuideLine.tsx rename to frontend/src/components/shared/chart/YGuideLine.tsx diff --git a/frontend/src/components/shared/chart/index.ts b/frontend/src/components/shared/chart/index.ts new file mode 100644 index 000000000..71b5b4aca --- /dev/null +++ b/frontend/src/components/shared/chart/index.ts @@ -0,0 +1,4 @@ +export { XAxis } from './XAxis'; +export { XAxisLabel } from './XAxisLabel'; +export { XGuideLine } from './XGuideLine'; +export { YGuideLine } from './YGuideLine'; diff --git a/frontend/src/components/shared/line-chart/Dot.tsx b/frontend/src/components/shared/line-chart/Dot.tsx new file mode 100644 index 000000000..fde82c162 --- /dev/null +++ b/frontend/src/components/shared/line-chart/Dot.tsx @@ -0,0 +1,42 @@ +import { LINE_CHART } from '@/constants/shared'; +import { cn } from '@/utils/shared'; + +interface DotProps { + x: number; + y: number; + color: string; + hasHoverEffect?: boolean; + ariaLabel: string; + className?: string; +} + +export const Dot = ({ + x, + y, + color, + ariaLabel, + hasHoverEffect = false, + className, +}: DotProps) => { + const { DOT_RADIUS } = LINE_CHART; + + return ( + + ); +}; diff --git a/frontend/src/components/shared/line-chart/Dots.tsx b/frontend/src/components/shared/line-chart/Dots.tsx index 8a4761109..ae8f75373 100644 --- a/frontend/src/components/shared/line-chart/Dots.tsx +++ b/frontend/src/components/shared/line-chart/Dots.tsx @@ -3,10 +3,11 @@ import { TooltipContent, TooltipTrigger, } from '@/components/shared/shadcn-ui'; -import { LINE_CHART } from '@/constants/shared'; import type { Coordinate, LineChartSeries } from '@/types/shared'; import { filterCoordinate } from '@/utils/shared'; +import { Dot } from './Dot'; + interface DotsProps { series: LineChartSeries; activeTooltip: boolean; @@ -22,26 +23,18 @@ export const Dots = ({ coordinate, color, }: DotsProps) => { - const { DOT_RADIUS } = LINE_CHART; - const filteredCoordinate = filterCoordinate(coordinate); if (!activeTooltip) { return ( <> {filteredCoordinate.map(({ x, y }, index) => ( - ))} @@ -57,19 +50,12 @@ export const Dots = ({ return ( - )} - {secondarySeries && ( - - - - - - + {backgroundGradientId && ( + + + + + + )} ); }; diff --git a/frontend/src/components/shared/line-chart/Series.tsx b/frontend/src/components/shared/line-chart/LineSeries.tsx similarity index 90% rename from frontend/src/components/shared/line-chart/Series.tsx rename to frontend/src/components/shared/line-chart/LineSeries.tsx index 06af8c93b..1b88a5167 100644 --- a/frontend/src/components/shared/line-chart/Series.tsx +++ b/frontend/src/components/shared/line-chart/LineSeries.tsx @@ -4,7 +4,7 @@ import type { Coordinate } from '@/types/shared'; import { Dots } from './Dots'; import { Line } from './Line'; -interface SeriesProps { +interface LineSeriesProps { coordinate: Coordinate[]; color: string; hasGradient?: boolean; @@ -14,7 +14,7 @@ interface SeriesProps { tooltipContent: (...args: string[]) => string; } -export const Series = ({ +export const LineSeries = ({ coordinate, color, hasGradient = false, @@ -22,7 +22,7 @@ export const Series = ({ series, activeTooltip, tooltipContent, -}: SeriesProps) => { +}: LineSeriesProps) => { return ( <> { + const [adjustedHeight, setAdjustedHeight] = useState(viewBoxHeight); + + const svgRef = useRef(null); + const xAxisRef = useRef(null); + + const xLabelList = useMemo(() => { + return barLineChartSeries.data.mainX.map((datum) => datum.amount ?? ''); + }, [barLineChartSeries.data.mainX]); + + const xCoordinate = useMemo(() => { + if (barLineChartSeries.data.mainX.length === 0) { + return []; + } + return getXCoordinate({ + svgWidth: viewBoxWidth, + xDataLength: barLineChartSeries.data.mainX.length, + }); + }, [viewBoxWidth, barLineChartSeries.data.mainX.length]); + + const barMaximumY = useMemo(() => { + return calculateMaximumY(barLineChartSeries.data.mainY); + }, [barLineChartSeries.data.mainY]); + + const barCoordinate = useMemo(() => { + if (barLineChartSeries.data.mainY.length === 0) { + return []; + } + return getCoordinate({ + svgWidth: viewBoxWidth, + adjustedHeight, + xDataLength: barLineChartSeries.data.mainX.length, + yData: barLineChartSeries.data.mainY, + maximumY: barMaximumY, + }); + }, [viewBoxWidth, adjustedHeight, barLineChartSeries, barMaximumY]); + + const lineMaximumY = useMemo(() => { + return calculateMaximumY(barLineChartSeries.data.subY); + }, [barLineChartSeries.data.subY]); + + const lineCoordinate = useMemo(() => { + if (barLineChartSeries.data.subY.length === 0) { + return []; + } + return getCoordinate({ + svgWidth: viewBoxWidth, + adjustedHeight, + xDataLength: barLineChartSeries.data.mainX.length, + yData: barLineChartSeries.data.subY, + maximumY: lineMaximumY, + }); + }, [viewBoxWidth, adjustedHeight, barLineChartSeries, lineMaximumY]); + + useLayoutEffect(() => { + const updateAdjustedHeight = () => { + if (!hasXAxis || !xAxisRef.current) { + setAdjustedHeight(viewBoxHeight); + return; + } + const xAxisBBox = xAxisRef.current.getBBox(); + setAdjustedHeight(xAxisBBox.y + xAxisBBox.height / 2); + }; + updateAdjustedHeight(); + }, [hasXAxis, viewBoxHeight]); + + return { + svgRef, + xAxisRef, + svgWidth: viewBoxWidth, + adjustedHeight, + xLabelList, + xCoordinate, + barCoordinate, + lineCoordinate, + }; +}; diff --git a/frontend/src/hooks/shared/bar-line-chart/useBarLineChartId.ts b/frontend/src/hooks/shared/bar-line-chart/useBarLineChartId.ts new file mode 100644 index 000000000..2207d1605 --- /dev/null +++ b/frontend/src/hooks/shared/bar-line-chart/useBarLineChartId.ts @@ -0,0 +1,13 @@ +import { useId } from 'react'; + +export const useBarLineChartId = () => { + const titleId = useId(); + const descId = useId(); + const lineGradientId = useId(); + + return { + titleId, + descId, + lineGradientId, + }; +}; diff --git a/frontend/src/hooks/shared/bar-line-chart/useDrawBarLine.ts b/frontend/src/hooks/shared/bar-line-chart/useDrawBarLine.ts new file mode 100644 index 000000000..3e652b7c2 --- /dev/null +++ b/frontend/src/hooks/shared/bar-line-chart/useDrawBarLine.ts @@ -0,0 +1,60 @@ +import type { Coordinate } from '@/types/shared'; +import { getBarHeight, getBarWidth } from '@/utils/shared'; + +interface UseDrawBarLineProps { + barX: number; + barY: number; + lineY: number; + hasXAxis: boolean; + viewBoxHeight: number; + viewBoxWidth: number; + xCoordinate: Coordinate[]; +} + +export const useDrawBarLine = ({ + barX, + barY, + lineY, + hasXAxis, + viewBoxHeight, + viewBoxWidth, + xCoordinate, +}: UseDrawBarLineProps) => { + const barHeight = getBarHeight({ + y: barY, + hasXAxis, + viewBoxHeight, + }); + const barWidth = getBarWidth({ + viewBoxWidth, + xDataLength: xCoordinate.length, + }); + + const interactionMiddle = barX; + const interactionTop = Math.min(barY, lineY); + const interactionWidth = barWidth; + const interactionHeight = getBarHeight({ + y: Math.min(lineY, barY), + hasXAxis, + viewBoxHeight, + }); + + const bottomY = interactionTop + interactionHeight; + const leftX = interactionMiddle - interactionWidth / 2; + const rightX = interactionMiddle + interactionWidth / 2; + + const interactionPathD = ` + M ${interactionMiddle} ${interactionTop} + H ${leftX} + V ${bottomY} + H ${rightX} + V ${interactionTop} + Z + `; + + return { + barHeight, + barWidth, + interactionPathD, + }; +}; diff --git a/frontend/src/hooks/shared/doughnut-chart/useDoughnutSegments.ts b/frontend/src/hooks/shared/doughnut-chart/useDoughnutSegments.ts index 4f3e11ba5..42232a37d 100644 --- a/frontend/src/hooks/shared/doughnut-chart/useDoughnutSegments.ts +++ b/frontend/src/hooks/shared/doughnut-chart/useDoughnutSegments.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo, useRef } from 'react'; import { DOUGHNUT_CHART_DEFAULT, RANKING_COLORS } from '@/constants/shared'; import type { DoughnutChartItem, - DoughtnutChartItemWithPercentage, + DoughnutChartItemWithPercentage, } from '@/types/shared'; import { computeChartDataWithPercentage, @@ -30,7 +30,7 @@ export const useDoughnutSegments = ({ const segmentRefs = useRef<(SVGPathElement | null)[]>([]); const labelRefs = useRef<(SVGTextElement | null)[]>([]); - const chartDataWithPercentage: DoughtnutChartItemWithPercentage[] = useMemo( + const chartDataWithPercentage: DoughnutChartItemWithPercentage[] = useMemo( () => computeChartDataWithPercentage(chartData), [chartData], ); diff --git a/frontend/src/hooks/shared/index.ts b/frontend/src/hooks/shared/index.ts index aa428d847..2f37324a8 100644 --- a/frontend/src/hooks/shared/index.ts +++ b/frontend/src/hooks/shared/index.ts @@ -26,3 +26,8 @@ export { useBarInitAnimation, usePathDAnimation, } from './bar-chart'; +export { + useBarLineChartId, + useDrawBarLine, + useBarLineChart, +} from './bar-line-chart'; diff --git a/frontend/src/hooks/shared/line-chart/useLineAnimation.ts b/frontend/src/hooks/shared/line-chart/useLineAnimation.ts index 06ada4612..27fc86f29 100644 --- a/frontend/src/hooks/shared/line-chart/useLineAnimation.ts +++ b/frontend/src/hooks/shared/line-chart/useLineAnimation.ts @@ -18,33 +18,35 @@ export const useLineAnimation = ({ const coordinateCountRef = useRef(0); useLayoutEffect(() => { - if (!lineRef.current || !pathD) { + const line = lineRef.current; + if (!line || !pathD) { return; } let rafId: number | null = null; // 좌표가 추가될 때마다 그려지는 애니메이션 적용 if (coordinateCountRef.current !== coordinateCount) { - coordinateCountRef.current = coordinateCount; - const totalLineLength = lineRef.current.getTotalLength(); - lineRef.current.style.strokeDasharray = `${totalLineLength}`; - lineRef.current.style.strokeDashoffset = `${totalLineLength - lineLengthRef.current}`; - lineRef.current.style.transition = 'none'; // 초기화 시에는 transition 비활성화 - lineLengthRef.current = totalLineLength; + const totalLineLength = line.getTotalLength(); + line.style.strokeDasharray = `${totalLineLength}`; + line.style.strokeDashoffset = `${totalLineLength - lineLengthRef.current}`; + line.style.transition = 'none'; // 초기화 시에는 transition 비활성화 // 위 css 적용되어 브라우저에서 렌더링된 후 // 다음 프레임에 transition 적용을 강제하기 위해 requestAnimationFrame 사용 rafId = requestAnimationFrame(() => { - if (lineRef.current) { - lineRef.current.style.transition = 'stroke-dashoffset 1s ease-in-out'; - lineRef.current.style.strokeDashoffset = '0'; + if (line) { + line.style.transition = 'stroke-dashoffset 1s ease-in-out'; + line.style.strokeDashoffset = '0'; + + coordinateCountRef.current = coordinateCount; + lineLengthRef.current = totalLineLength; } }); } else { // stroke-dasharray, stroke-dashoffset 속성 제거 선이 끊기는 현상 방지 - lineRef.current.style.removeProperty('stroke-dasharray'); - lineRef.current.style.removeProperty('stroke-dashoffset'); - lineRef.current.style.transition = 'd 0.5s ease-in-out'; + line.style.removeProperty('stroke-dasharray'); + line.style.removeProperty('stroke-dashoffset'); + line.style.transition = 'd 0.5s ease-in-out'; } return () => { @@ -52,7 +54,7 @@ export const useLineAnimation = ({ cancelAnimationFrame(rafId); } }; - }, [lineRef, pathD, coordinateCount]); + }, [pathD, coordinateCount]); return { lineRef, diff --git a/frontend/src/hooks/shared/line-chart/useLineChart.ts b/frontend/src/hooks/shared/line-chart/useLineChart.ts index 6a3f1cd90..e2cb73305 100644 --- a/frontend/src/hooks/shared/line-chart/useLineChart.ts +++ b/frontend/src/hooks/shared/line-chart/useLineChart.ts @@ -65,7 +65,8 @@ export const useLineChart = ({ return getCoordinate({ svgWidth: viewBoxWidth, adjustedHeight, - series: primarySeries, + xDataLength: primarySeries.data.mainX.length, + yData: primarySeries.data.mainY, maximumY, }); }, [viewBoxWidth, adjustedHeight, primarySeries, maximumY]); @@ -90,7 +91,8 @@ export const useLineChart = ({ return getCoordinate({ svgWidth: viewBoxWidth, adjustedHeight, - series: secondarySeries, + xDataLength: secondarySeries.data.mainX.length, + yData: secondarySeries.data.mainY, maximumY, }); }, [viewBoxWidth, adjustedHeight, secondarySeries, maximumY]); diff --git a/frontend/src/mocks/data/index.ts b/frontend/src/mocks/data/index.ts index 18fb92781..5431c0eaf 100644 --- a/frontend/src/mocks/data/index.ts +++ b/frontend/src/mocks/data/index.ts @@ -3,4 +3,7 @@ export { PRIMARY_SERIES_MOCK, SECONDARY_SERIES_MOCK, WEEKLY_DATA, + BAR_LINE_WEEKLY_MOCK, + BAR_LINE_MONTHLY_MOCK, + BAR_LINE_REALTIME_MOCK, } from './storybook'; diff --git a/frontend/src/mocks/data/storybook/barLineChartStoryData.ts b/frontend/src/mocks/data/storybook/barLineChartStoryData.ts new file mode 100644 index 000000000..07cea846d --- /dev/null +++ b/frontend/src/mocks/data/storybook/barLineChartStoryData.ts @@ -0,0 +1,239 @@ +import type { BarLineChartSeries } from '@/types/shared'; + +export const BAR_LINE_WEEKLY_MOCK: BarLineChartSeries = { + color: 'var(--color-grey-400)', + data: { + mainX: [ + { amount: '1월 15일', unit: '' }, + { amount: '1월 16일', unit: '' }, + { amount: '1월 17일', unit: '' }, + { amount: '1월 18일', unit: '' }, + { amount: '1월 19일', unit: '' }, + { amount: '1월 20일', unit: '' }, + { amount: '오늘', unit: '' }, + ], + subX: [ + { amount: '1월 15일', unit: '' }, + { amount: '1월 16일', unit: '' }, + { amount: '1월 17일', unit: '' }, + { amount: '1월 18일', unit: '' }, + { amount: '1월 19일', unit: '' }, + { amount: '1월 20일', unit: '' }, + { amount: '오늘', unit: '' }, + ], + mainY: [ + { amount: 17.4, unit: '만' }, + { amount: 10.2, unit: '만' }, + { amount: 13.5, unit: '만' }, + { amount: 15.8, unit: '만' }, + { amount: 18.7, unit: '만' }, + { amount: 15.6, unit: '만' }, + { amount: 11.1, unit: '만' }, + ], + subY: [ + { amount: 10, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 9, unit: '건' }, + { amount: 13, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 13, unit: '건' }, + { amount: 9, unit: '건' }, + ], + }, +}; + +export const BAR_LINE_MONTHLY_MOCK: BarLineChartSeries = { + color: 'var(--color-grey-400)', + data: { + mainX: [ + { amount: '1/1', unit: '' }, + { amount: '1/2', unit: '' }, + { amount: '1/3', unit: '' }, + { amount: '1/4', unit: '' }, + { amount: '1/5', unit: '' }, + { amount: '1/6', unit: '' }, + { amount: '1/7', unit: '' }, + { amount: '1/8', unit: '' }, + { amount: '1/9', unit: '' }, + { amount: '1/10', unit: '' }, + { amount: '1/11', unit: '' }, + { amount: '1/12', unit: '' }, + { amount: '1/13', unit: '' }, + { amount: '1/14', unit: '' }, + { amount: '1/15', unit: '' }, + { amount: '1/16', unit: '' }, + { amount: '1/17', unit: '' }, + { amount: '1/18', unit: '' }, + { amount: '1/19', unit: '' }, + { amount: '1/20', unit: '' }, + { amount: '1/21', unit: '' }, + { amount: '1/22', unit: '' }, + { amount: '1/23', unit: '' }, + { amount: '1/24', unit: '' }, + { amount: '1/25', unit: '' }, + { amount: '1/26', unit: '' }, + { amount: '1/27', unit: '' }, + { amount: '1/28', unit: '' }, + { amount: '1/29', unit: '' }, + { amount: '오늘', unit: '' }, + ], + subX: [ + { amount: '1/1', unit: '' }, + { amount: '1/2', unit: '' }, + { amount: '1/3', unit: '' }, + { amount: '1/4', unit: '' }, + { amount: '1/5', unit: '' }, + { amount: '1/6', unit: '' }, + { amount: '1/7', unit: '' }, + { amount: '1/8', unit: '' }, + { amount: '1/9', unit: '' }, + { amount: '1/10', unit: '' }, + { amount: '1/11', unit: '' }, + { amount: '1/12', unit: '' }, + { amount: '1/13', unit: '' }, + { amount: '1/14', unit: '' }, + { amount: '1/15', unit: '' }, + { amount: '1/16', unit: '' }, + { amount: '1/17', unit: '' }, + { amount: '1/18', unit: '' }, + { amount: '1/19', unit: '' }, + { amount: '1/20', unit: '' }, + { amount: '1/21', unit: '' }, + { amount: '1/22', unit: '' }, + { amount: '1/23', unit: '' }, + { amount: '1/24', unit: '' }, + { amount: '1/25', unit: '' }, + { amount: '1/26', unit: '' }, + { amount: '1/27', unit: '' }, + { amount: '1/28', unit: '' }, + { amount: '1/29', unit: '' }, + { amount: '오늘', unit: '' }, + ], + mainY: [ + { amount: 20.1, unit: '만' }, + { amount: 7.5, unit: '만' }, + { amount: 12.7, unit: '만' }, + { amount: 18.2, unit: '만' }, + { amount: 23.4, unit: '만' }, + { amount: 17.9, unit: '만' }, + { amount: 9.1, unit: '만' }, + { amount: 20.4, unit: '만' }, + { amount: 7.3, unit: '만' }, + { amount: 12.8, unit: '만' }, + { amount: 18.1, unit: '만' }, + { amount: 23.3, unit: '만' }, + { amount: 17.8, unit: '만' }, + { amount: 9.0, unit: '만' }, + { amount: 20.2, unit: '만' }, + { amount: 7.4, unit: '만' }, + { amount: 12.9, unit: '만' }, + { amount: 18.3, unit: '만' }, + { amount: 23.2, unit: '만' }, + { amount: 17.7, unit: '만' }, + { amount: 8.9, unit: '만' }, + { amount: 20.0, unit: '만' }, + { amount: 7.2, unit: '만' }, + { amount: 12.6, unit: '만' }, + { amount: 18.0, unit: '만' }, + { amount: 17.9, unit: '만' }, + { amount: 23.3, unit: '만' }, + { amount: 17.8, unit: '만' }, + { amount: 9.0, unit: '만' }, + { amount: 9.1, unit: '만' }, + ], + subY: [ + { amount: 10, unit: '건' }, + { amount: 9, unit: '건' }, + { amount: 13, unit: '건' }, + { amount: 13, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 11, unit: '건' }, + { amount: 11, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 12, unit: '건' }, + { amount: 9, unit: '건' }, + { amount: 13, unit: '건' }, + { amount: 14, unit: '건' }, + ], + }, +}; + +export const BAR_LINE_REALTIME_MOCK: BarLineChartSeries = { + color: 'var(--color-grey-400)', + data: { + mainX: [ + { amount: '00:00', unit: '' }, + { amount: '02:00', unit: '' }, + { amount: '04:00', unit: '' }, + { amount: '06:00', unit: '' }, + { amount: '08:00', unit: '' }, + { amount: '10:00', unit: '' }, + { amount: '12:00', unit: '' }, + { amount: '14:00', unit: '' }, + { amount: '16:00', unit: '' }, + { amount: '18:00', unit: '' }, + { amount: '20:00', unit: '' }, + { amount: '22:00', unit: '' }, + ], + subX: [ + { amount: '00:00', unit: '' }, + { amount: '02:00', unit: '' }, + { amount: '04:00', unit: '' }, + { amount: '06:00', unit: '' }, + { amount: '08:00', unit: '' }, + { amount: '10:00', unit: '' }, + { amount: '12:00', unit: '' }, + { amount: '14:00', unit: '' }, + { amount: '16:00', unit: '' }, + { amount: '18:00', unit: '' }, + { amount: '20:00', unit: '' }, + { amount: '22:00', unit: '' }, + ], + mainY: [ + { amount: 6.2, unit: '만' }, + { amount: 8.1, unit: '만' }, + { amount: 7.4, unit: '만' }, + { amount: 10.3, unit: '만' }, + { amount: 9.6, unit: '만' }, + { amount: null, unit: '만' }, + { amount: null, unit: '만' }, + { amount: null, unit: '만' }, + { amount: null, unit: '만' }, + { amount: null, unit: '만' }, + { amount: null, unit: '만' }, + { amount: null, unit: '만' }, + ], + subY: [ + { amount: 4, unit: '건' }, + { amount: 5, unit: '건' }, + { amount: 4, unit: '건' }, + { amount: 6, unit: '건' }, + { amount: 5, unit: '건' }, + { amount: null, unit: '건' }, + { amount: null, unit: '건' }, + { amount: null, unit: '건' }, + { amount: null, unit: '건' }, + { amount: null, unit: '건' }, + { amount: null, unit: '건' }, + { amount: null, unit: '건' }, + ], + }, +}; diff --git a/frontend/src/mocks/data/storybook/index.ts b/frontend/src/mocks/data/storybook/index.ts index e62a1894c..a266cedf1 100644 --- a/frontend/src/mocks/data/storybook/index.ts +++ b/frontend/src/mocks/data/storybook/index.ts @@ -5,3 +5,8 @@ export { WEEKLY_DATA, } from './lineChartStoryData'; export { STACK_BAR, STACK_BAR_HOURLY } from './barChartStoryData'; +export { + BAR_LINE_WEEKLY_MOCK, + BAR_LINE_MONTHLY_MOCK, + BAR_LINE_REALTIME_MOCK, +} from './barLineChartStoryData'; diff --git a/frontend/src/types/shared/bar-line-chart/barLineChartDataType.ts b/frontend/src/types/shared/bar-line-chart/barLineChartDataType.ts new file mode 100644 index 000000000..cd732dde4 --- /dev/null +++ b/frontend/src/types/shared/bar-line-chart/barLineChartDataType.ts @@ -0,0 +1,13 @@ +import type { ChartDatum } from '@/types/shared/chart'; + +export interface BarLineChartData { + mainX: ChartDatum[]; + subX: ChartDatum[]; + mainY: ChartDatum[]; // bar + subY: ChartDatum[]; // line +} + +export interface BarLineChartSeries { + data: BarLineChartData; + color: string; +} diff --git a/frontend/src/types/shared/bar-line-chart/index.ts b/frontend/src/types/shared/bar-line-chart/index.ts new file mode 100644 index 000000000..b98ec29e0 --- /dev/null +++ b/frontend/src/types/shared/bar-line-chart/index.ts @@ -0,0 +1,4 @@ +export type { + BarLineChartData, + BarLineChartSeries, +} from './barLineChartDataType'; diff --git a/frontend/src/types/shared/doughnutChartItem.ts b/frontend/src/types/shared/doughnutChartItem.ts index d95ba8794..4cd20ef4c 100644 --- a/frontend/src/types/shared/doughnutChartItem.ts +++ b/frontend/src/types/shared/doughnutChartItem.ts @@ -4,7 +4,7 @@ export interface DoughnutChartItem { color?: string; } -export interface DoughtnutChartItemWithPercentage { +export interface DoughnutChartItemWithPercentage { percentage: number; label: string; color?: string; diff --git a/frontend/src/types/shared/index.ts b/frontend/src/types/shared/index.ts index 1fb4323aa..4977e94ba 100644 --- a/frontend/src/types/shared/index.ts +++ b/frontend/src/types/shared/index.ts @@ -3,7 +3,7 @@ export type { BusinessHour } from './businessHour'; export type { DayOfWeek } from './dayOfWeek'; export type { DoughnutChartItem, - DoughtnutChartItemWithPercentage, + DoughnutChartItemWithPercentage, } from './doughnutChartItem'; export type { RouteHandle } from './routeHandle'; export type { StoreInfo } from './storeInfo'; @@ -26,3 +26,4 @@ export type { } from './bar-chart'; export type { ChartData, ChartSeries, ChartDatum } from './chart'; export type { EventSourceMessage } from './eventSourceMessage'; +export type { BarLineChartData, BarLineChartSeries } from './bar-line-chart'; diff --git a/frontend/src/utils/shared/bar-chart/getBarHeight.ts b/frontend/src/utils/shared/bar-chart/getBarHeight.ts new file mode 100644 index 000000000..3c21e45e5 --- /dev/null +++ b/frontend/src/utils/shared/bar-chart/getBarHeight.ts @@ -0,0 +1,19 @@ +import { BAR_CHART } from '@/constants/shared'; + +// 바 전체 높이 계산 (바의 상단 y좌표 부터 x축 또는 svg 하단까지의 거리) +export const getBarHeight = ({ + y, + hasXAxis, + viewBoxHeight, +}: { + y: number; + hasXAxis: boolean; + viewBoxHeight: number; +}) => { + const { XAXIS_Y_OFFSET, XAXIS_STROKE_WIDTH } = BAR_CHART; + if (hasXAxis) { + // x축이 있을 때는 x축의 y위치 만큼을 빼고 축 높이의 0.5배 만큼 더 빼줘야 함 + return viewBoxHeight - XAXIS_Y_OFFSET - y - XAXIS_STROKE_WIDTH / 2; // x축이 있을 때 바 높이는 y좌표 ~ x 축까지 거리 + } + return viewBoxHeight - y; // x축이 없을 떄 바 높이는 y좌표 ~ svg 최하단 까지 거리 +}; diff --git a/frontend/src/utils/shared/bar-chart/getBarWidth.ts b/frontend/src/utils/shared/bar-chart/getBarWidth.ts new file mode 100644 index 000000000..06a8ba0ed --- /dev/null +++ b/frontend/src/utils/shared/bar-chart/getBarWidth.ts @@ -0,0 +1,10 @@ +// 바 너비는 막대 간격의 50%로 설정 (막대 간격은 viewBoxWidth / x축의 지점 개수) +export const getBarWidth = ({ + viewBoxWidth, + xDataLength, +}: { + viewBoxWidth: number; + xDataLength: number; +}) => { + return (viewBoxWidth / xDataLength) * 0.5; +}; diff --git a/frontend/src/utils/shared/bar-chart/getLabelContentText.ts b/frontend/src/utils/shared/bar-chart/getLabelContentText.ts new file mode 100644 index 000000000..622d930a1 --- /dev/null +++ b/frontend/src/utils/shared/bar-chart/getLabelContentText.ts @@ -0,0 +1,33 @@ +import type { + AllBarChartSeries, + BarChartDatum, + StackBarDatum, +} from '@/types/shared'; + +// 바 위에 표시될 라벨 내용 텍스트 생성 +export const getLabelContentText = ({ + isStackBar, + index, + series, +}: { + isStackBar: boolean; + index: number; + series: AllBarChartSeries; +}) => { + if (isStackBar) { + // 스택바 그래프일 때는 mainY의 각 항목이 배열이므로 각 스택의 합계를 계산하여 라벨에 표시 + const stackValues = series.data.mainY[index] as StackBarDatum; + const total = stackValues.reduce((sum, item) => { + if (typeof item.amount === 'number') { + return sum + item.amount; + } + return sum; + }, 0); + const unit = stackValues[0]?.unit || ''; // 단위는 첫 번째 항목의 단위를 사용 + return `${total} ${unit}`; + } else { + // 일반 바 그래프일 때는 mainY의 단일 값을 라벨에 표시 + const value = series.data.mainY[index] as BarChartDatum; + return `${value.amount} ${value.unit}`; + } +}; diff --git a/frontend/src/utils/shared/bar-chart/index.ts b/frontend/src/utils/shared/bar-chart/index.ts index 29e0abbfa..2b4bac460 100644 --- a/frontend/src/utils/shared/bar-chart/index.ts +++ b/frontend/src/utils/shared/bar-chart/index.ts @@ -2,3 +2,6 @@ export { checkIsStackBarChart } from './checkIsStackBarChart'; export { getBarSegmentInfoList } from './getBarSegmentInfoList'; export { getTooltipContent } from './getTooltipContent'; export { getStackTotalAmount } from './getStackTotalAmount'; +export { getBarHeight } from './getBarHeight'; +export { getBarWidth } from './getBarWidth'; +export { getLabelContentText } from './getLabelContentText'; diff --git a/frontend/src/utils/shared/chart/calculateMaximumY.ts b/frontend/src/utils/shared/chart/calculateMaximumY.ts new file mode 100644 index 000000000..397aea1a1 --- /dev/null +++ b/frontend/src/utils/shared/chart/calculateMaximumY.ts @@ -0,0 +1,11 @@ +import type { ChartDatum } from '@/types/shared'; + +export const calculateMaximumY = (data: ChartDatum[]) => { + const totalData = data + .filter((datum) => datum.amount !== null) + .map((datum) => Number(datum.amount ?? 0)); + const maximumAmount = totalData.length > 0 ? Math.max(...totalData) : 10; + const adjustedMaximumAmount = + Math.ceil(Math.ceil(maximumAmount * 1.5) / 10) * 10; + return adjustedMaximumAmount; +}; diff --git a/frontend/src/utils/shared/line-chart/getXCoordinate.ts b/frontend/src/utils/shared/chart/getXCoordinate.ts similarity index 100% rename from frontend/src/utils/shared/line-chart/getXCoordinate.ts rename to frontend/src/utils/shared/chart/getXCoordinate.ts diff --git a/frontend/src/utils/shared/chart/index.ts b/frontend/src/utils/shared/chart/index.ts new file mode 100644 index 000000000..2bc015f8d --- /dev/null +++ b/frontend/src/utils/shared/chart/index.ts @@ -0,0 +1,2 @@ +export { getXCoordinate } from './getXCoordinate'; +export { calculateMaximumY } from './calculateMaximumY'; diff --git a/frontend/src/utils/shared/doughnut-chart/doughnutChart.ts b/frontend/src/utils/shared/doughnut-chart/doughnutChart.ts index 56e04394b..a8e145427 100644 --- a/frontend/src/utils/shared/doughnut-chart/doughnutChart.ts +++ b/frontend/src/utils/shared/doughnut-chart/doughnutChart.ts @@ -1,12 +1,12 @@ import { DOUGHNUT_CHART_DEFAULT } from '@/constants/shared'; import type { DoughnutChartItem, - DoughtnutChartItemWithPercentage, + DoughnutChartItemWithPercentage, } from '@/types/shared'; export const computeChartDataWithPercentage = ( chartData: DoughnutChartItem[], -): DoughtnutChartItemWithPercentage[] => { +): DoughnutChartItemWithPercentage[] => { const totalValue = chartData.reduce((sum, item) => sum + item.value, 0); if (totalValue === 0) { return chartData.map((item) => ({ diff --git a/frontend/src/utils/shared/getCoordinate.ts b/frontend/src/utils/shared/getCoordinate.ts index 1bc8800f1..57bba17ee 100644 --- a/frontend/src/utils/shared/getCoordinate.ts +++ b/frontend/src/utils/shared/getCoordinate.ts @@ -1,11 +1,12 @@ -import type { ChartSeries } from '@/types/shared'; +import type { ChartDatum } from '@/types/shared'; // 바, 라인 그래프에서 사용되는 데이터별 좌표 점 계산 유틸 -interface GetCoordinateArgs { +interface GetCoordinateArgs { svgWidth: number; adjustedHeight: number; - series: T; maximumY: number; + xDataLength: number; + yData: ChartDatum[]; } interface Coordinate { @@ -13,13 +14,16 @@ interface Coordinate { y: number | null; } -export const getCoordinate = ({ +export const getCoordinate = ({ svgWidth, adjustedHeight, - series, maximumY, -}: GetCoordinateArgs): Coordinate[] => { - const xDataLength = series.data.mainX.length; + xDataLength, + yData, +}: GetCoordinateArgs): Coordinate[] => { + if (xDataLength === 0 || yData.length === 0 || maximumY === 0) { + return []; + } const intervalX = svgWidth / xDataLength; const lastX = intervalX * (xDataLength - 1); @@ -29,12 +33,10 @@ export const getCoordinate = ({ return { x: index * intervalX + offsetX, y: - series.data.mainY[index]?.amount === null || - series.data.mainY[index]?.amount === undefined + yData[index]?.amount === null || yData[index]?.amount === undefined ? null : adjustedHeight - - (Number(series.data.mainY[index].amount) / maximumY) * - adjustedHeight, + (Number(yData[index].amount) / maximumY) * adjustedHeight, }; }); }; diff --git a/frontend/src/utils/shared/index.ts b/frontend/src/utils/shared/index.ts index 256dcb208..87edaff21 100644 --- a/frontend/src/utils/shared/index.ts +++ b/frontend/src/utils/shared/index.ts @@ -26,7 +26,8 @@ export { } from './calendar'; export { formatPriceWithComma } from './formatPriceWithComma'; export { formatNumber, formatNumberInTenThousands } from './formatNumber'; -export { getXCoordinate, filterCoordinate } from './line-chart'; +export { filterCoordinate } from './line-chart'; +export { getXCoordinate, calculateMaximumY } from './chart'; export { computeChartDataWithPercentage, @@ -44,4 +45,7 @@ export { checkIsStackBarChart, getTooltipContent, getStackTotalAmount, + getBarHeight, + getBarWidth, + getLabelContentText, } from './bar-chart'; diff --git a/frontend/src/utils/shared/line-chart/index.ts b/frontend/src/utils/shared/line-chart/index.ts index 259266328..5ada28d5b 100644 --- a/frontend/src/utils/shared/line-chart/index.ts +++ b/frontend/src/utils/shared/line-chart/index.ts @@ -1,2 +1 @@ -export { getXCoordinate } from './getXCoordinate'; export { filterCoordinate } from './filterCoordinate';