diff --git a/babel.config.js b/babel.config.js index f842b77..14f311c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,17 @@ module.exports = { presets: ['module:metro-react-native-babel-preset'], + plugins: [ + [ + 'module-resolver', + { + root: ['./'], + alias: { + components: './src/components', + navigation: './src/navigation', + screens: './src/screens', + utils: './src/utils', + }, + }, + ], + ], }; diff --git a/package.json b/package.json index 26b1f33..b6625ea 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/react": "^18.0.24", "@types/react-test-renderer": "^18.0.0", "babel-jest": "^29.2.1", + "babel-plugin-module-resolver": "^5.0.0", "eslint": "^8.19.0", "jest": "^29.2.1", "metro-react-native-babel-preset": "0.73.9", diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a0bcfb5..dcd8266 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,12 +1,12 @@ -import {ReactNode} from 'react'; -import {Pressable, PressableProps, StyleSheet} from 'react-native'; +import { ReactNode } from 'react'; +import { Pressable, PressableProps, StyleSheet } from 'react-native'; import Text from './Text'; type ButtonProps = { title?: string; children?: ReactNode; } & PressableProps; -function Button({title, children, ...props}: ButtonProps) { +function Button({ title, children, ...props }: ButtonProps) { return ( {children} diff --git a/src/components/ScreenContainer/index.tsx b/src/components/ScreenContainer/index.tsx index 00facdc..be1e680 100644 --- a/src/components/ScreenContainer/index.tsx +++ b/src/components/ScreenContainer/index.tsx @@ -1,10 +1,10 @@ -import {ReactNode} from 'react'; -import {StyleSheet, View} from 'react-native'; +import { ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; type ScreenContainerProps = { children: ReactNode; }; -function ScreenContainer({children}: ScreenContainerProps) { +function ScreenContainer({ children }: ScreenContainerProps) { return {children}; } diff --git a/src/components/Text.tsx b/src/components/Text.tsx index db55246..49c97e7 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -6,7 +6,7 @@ import { type TextProps = RNTextProps; -function Text({style, ...props}: TextProps) { +function Text({ style, ...props }: TextProps) { return ; } diff --git a/src/navigation/RootNavigation.tsx b/src/navigation/RootNavigation.tsx index 80b4309..3e54054 100644 --- a/src/navigation/RootNavigation.tsx +++ b/src/navigation/RootNavigation.tsx @@ -1,8 +1,10 @@ import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import C01LineChartScreen from '../screens/C01LineChartScreen'; -import HomeScreen from '../screens/HomeScreen'; +import C01LineChartScreen from 'screens/C01LineChartScreen'; +import C02LineChartWithOptionsScreen from 'screens/C02LineChartWithOptionsScreen'; +import HomeScreen from 'screens/HomeScreen'; + import { RootStackParamsList } from './types'; const Stack = createNativeStackNavigator(); @@ -17,6 +19,11 @@ function RootNavigation() { component={C01LineChartScreen} options={{ animation: 'none' }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 70b3c35..e3f470b 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,4 +1,5 @@ export type RootStackParamsList = { C01LineChart: undefined; + C02LineChartWithOptions: undefined; Home: undefined; }; diff --git a/src/navigation/useNavigation.ts b/src/navigation/useNavigation.ts index fbb6a2e..0c51552 100644 --- a/src/navigation/useNavigation.ts +++ b/src/navigation/useNavigation.ts @@ -1,6 +1,6 @@ -import {useNavigation as useRNNavigation} from '@react-navigation/native'; -import {NativeStackNavigationProp} from '@react-navigation/native-stack'; -import {RootStackParamsList} from './types'; +import { useNavigation as useRNNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamsList } from './types'; type RootStackNavigationProp = NativeStackNavigationProp; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/Lines.tsx b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/Lines.tsx new file mode 100644 index 0000000..51db84f --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/Lines.tsx @@ -0,0 +1,43 @@ +import { G, Path } from 'react-native-svg'; +import { LinesOptions, TimeSeries } from '../types'; + +const DEFAULT_COLORS = [ + 'blue', + 'skyblue', + 'green', + 'brown', + 'gray', + 'orange', + 'purple', + 'red', + 'pink', + 'black', +]; + +type LinesProps = LinesOptions & { + series: TimeSeries[]; + lineFunc: d3.Line; +}; +function Lines({ + series, + lineFunc, + colors = DEFAULT_COLORS, + lineWidth = 1, +}: LinesProps) { + return ( + + {series.map((sr, i) => ( + + ))} + + ); +} + +export default Lines; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/XAxis.tsx b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/XAxis.tsx new file mode 100644 index 0000000..f009090 --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/XAxis.tsx @@ -0,0 +1,111 @@ +import * as d3 from 'd3'; +import { G, Line, Text } from 'react-native-svg'; + +import dateFormat from 'utils/dateFormat'; +import PaneBoundary from 'utils/PaneBoundary'; + +import { TimeAxisOptions } from '../types'; + +const DEFAULT_TICK_LENGTH = 6; + +type XAxisProps = TimeAxisOptions & { + scale: d3.ScaleTime; + paneBoundary: PaneBoundary; +}; +function XAxis({ + scale, + paneBoundary, + x = 0, + y = paneBoundary.y1, + + enabled = true, + + lineColor = 'black', + lineWidth = 1, + + showTicks = true, + ticks: _ticks, + + tickLength = DEFAULT_TICK_LENGTH, + tickWidth = 1, + tickColor = 'black', + + tickLabelSize, + tickLabelFont, + tickLabelWeight, + tickLabelColor = 'black', + tickLabelFormatter = dateFormat, + + showGridLines = false, + gridLineWidth = 1, + gridLineColor = 'lightgray', +}: XAxisProps) { + const range = scale.range(); + const ticks = !_ticks + ? scale.ticks() + : typeof _ticks === 'function' + ? _ticks(scale) + : _ticks; + + if (!enabled) { + return null; + } + return ( + + + + {showTicks && ( + <> + {ticks.map(tick => ( + + ))} + {ticks.map(tick => ( + + {tickLabelFormatter(tick)} + + ))} + + )} + + {showGridLines && + ticks.map(tick => ( + + ))} + + ); +} + +export default XAxis; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/YAxis.tsx b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/YAxis.tsx new file mode 100644 index 0000000..832682e --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/YAxis.tsx @@ -0,0 +1,108 @@ +import * as d3 from 'd3'; +import { G, Line, Text } from 'react-native-svg'; + +import PaneBoundary from 'utils/PaneBoundary'; + +import { LinearAxisOptions } from '../types'; + +const DEFAULT_TICK_LENGTH = 6; + +type YAxisProps = LinearAxisOptions & { + scale: d3.ScaleLinear; + paneBoundary: PaneBoundary; +}; +function YAxis({ + scale, + paneBoundary, + x = paneBoundary.x1, + y = 0, + + enabled = true, + + lineColor = 'black', + lineWidth = 1, + + showTicks = true, + ticks: _ticks, + tickLength = DEFAULT_TICK_LENGTH, + tickWidth = 1, + tickColor = 'black', + + tickLabelSize, + tickLabelFont, + tickLabelWeight, + tickLabelColor = 'black', + tickLabelFormatter = val => `${val}`, + + showGridLines = true, + gridLineWidth = 1, + gridLineColor = 'lightgray', +}: YAxisProps) { + const range = scale.range(); + const ticks = !_ticks + ? scale.ticks() + : typeof _ticks === 'function' + ? _ticks(scale) + : _ticks; + + if (!enabled) { + return null; + } + return ( + + + {showTicks && ( + <> + {ticks.map(tick => ( + + ))} + {ticks.map(tick => ( + + {tickLabelFormatter(tick)} + + ))} + + )} + + {showGridLines && + ticks.map(tick => ( + + ))} + + ); +} + +export default YAxis; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/index.tsx b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/index.tsx new file mode 100644 index 0000000..b6f9925 --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/LineChartBody/index.tsx @@ -0,0 +1,45 @@ +import { Fragment } from 'react'; + +import PaneBoundary from 'utils/PaneBoundary'; + +import { + TimeSeries, + TimeAxisOptions, + LinearAxisOptions, + LinesOptions, +} from '../types'; +import XAxis from './XAxis'; +import YAxis from './YAxis'; +import Lines from './Lines'; + +type LineChartBodyProps = { + series: TimeSeries[]; + xScale: d3.ScaleTime; + yScale: d3.ScaleLinear; + lineFunc: d3.Line; + paneBoundary: PaneBoundary; + + xAxisOptions?: TimeAxisOptions; + yAxisOptions?: LinearAxisOptions; + linesOptions?: LinesOptions; +}; +function LineChartBody({ + series, + xScale, + yScale, + lineFunc, + paneBoundary, + xAxisOptions, + yAxisOptions, + linesOptions, +}: LineChartBodyProps) { + return ( + + + + + + ); +} + +export default LineChartBody; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/constants.ts b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/constants.ts new file mode 100644 index 0000000..d6387e6 --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_X_AXIS_HEIGHT = 35; +export const DEFAULT_Y_AXIS_WIDTH = 50; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/getLinearScale.ts b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/getLinearScale.ts new file mode 100644 index 0000000..cb02f05 --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/getLinearScale.ts @@ -0,0 +1,32 @@ +import * as d3 from 'd3'; +import { TimeSeries } from './types'; + +function getLinearScale(series: TimeSeries[], range: [number, number]) { + if (!series?.length) { + return null; + } + if (range[0] === 0 && range[1] === 0) { + return null; + } + + const allData = series.reduce( + (acc, sr) => [...acc, ...sr.data], + [] as TimeSeries['data'] + ); + if (!allData.length) { + return null; + } + + const domain = d3.extent(allData, dt => dt.value) as [number, number]; + + const offsetDelta = 0.2; + const offset = (domain[1] - domain[0]) * offsetDelta; + domain[0] -= offset; + domain[1] += offset; + + const scale = d3.scaleLinear().domain(domain).range(range); + + return scale; +} + +export default getLinearScale; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/getTimeScale.ts b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/getTimeScale.ts new file mode 100644 index 0000000..75d9dca --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/getTimeScale.ts @@ -0,0 +1,25 @@ +import * as d3 from 'd3'; +import { TimeSeries } from './types'; + +function getTimeScale( + series: TimeSeries[] | undefined, + range: [number, number] +) { + if (!series?.length) { + return null; + } + if (range[0] === 0 && range[1] === 0) { + return null; + } + + const allData = series.reduce( + (acc, sr) => [...acc, ...sr.data], + [] as TimeSeries['data'] + ); + const domain = d3.extent(allData, dt => dt.date) as [Date, Date]; + const scale = d3.scaleTime().domain(domain).range(range); + + return scale; +} + +export default getTimeScale; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/index.tsx b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/index.tsx new file mode 100644 index 0000000..d9870f0 --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/index.tsx @@ -0,0 +1,108 @@ +import * as d3 from 'd3'; +import { useEffect } from 'react'; +import { Svg, SvgProps } from 'react-native-svg'; +import { useImmer } from 'use-immer'; + +import PaneBoundary from 'utils/PaneBoundary'; + +import { + TimeSeries, + TimeAxisOptions, + LinearAxisOptions, + LinesOptions, + PaneOptions, +} from './types'; +import getTimeScale from './getTimeScale'; +import getLinearScale from './getLinearScale'; +import LineChartBody from './LineChartBody'; +import { DEFAULT_X_AXIS_HEIGHT, DEFAULT_Y_AXIS_WIDTH } from './constants'; + +type LineChartProps = { + series: TimeSeries[]; + width?: string | number; + height?: string | number; + xAxisOptions?: TimeAxisOptions; + yAxisOptions?: LinearAxisOptions; + linesOptions?: LinesOptions; + paneOptions?: PaneOptions; +}; +function LineChart({ + series, + width = '100%', + height = 200, + xAxisOptions, + yAxisOptions, + linesOptions, + paneOptions = {}, +}: LineChartProps) { + const [state, setState] = useImmer({ + width: 0, + height: 0, + paneBoundary: new PaneBoundary({ x1: 0, x2: 0, y1: 0, y2: 0 }), + }); + + const { margin, marginTop, marginLeft, marginRight, marginBottom } = + paneOptions || {}; + + const updatePaneBoundary = (width?: number, height?: number) => { + setState(dr => { + if (width !== undefined) { + dr.width = Math.round(width); + } + if (height !== undefined) { + dr.height = Math.round(height); + } + + dr.paneBoundary = new PaneBoundary({ + x1: marginLeft ?? margin ?? DEFAULT_Y_AXIS_WIDTH, + x2: dr.width - (marginRight ?? margin ?? 10), + y1: dr.height - (marginBottom ?? margin ?? DEFAULT_X_AXIS_HEIGHT), + y2: marginTop ?? margin ?? 10, + }); + }); + }; + + useEffect(() => { + if (!state.width || !state.height) { + return; + } + updatePaneBoundary(); + }, [margin, marginTop, marginLeft, marginRight, marginBottom]); + + const onLayout: SvgProps['onLayout'] = evt => { + const { layout } = evt.nativeEvent; + updatePaneBoundary(layout.width, layout.height); + }; + + const xScale = getTimeScale(series, state.paneBoundary.xs); + const yScale = getLinearScale(series, state.paneBoundary.ys); + const lineFunc = + !xScale || !yScale + ? null + : d3 + .line() + .defined(dt => !isNaN(dt.value)) + .x(dt => xScale(dt.date)) + .y(dt => yScale(dt.value)); + + const loaded = xScale !== null && yScale !== null && lineFunc !== null; + + return ( + + {loaded && ( + + )} + + ); +} + +export default LineChart; diff --git a/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/types.ts b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/types.ts new file mode 100644 index 0000000..2daf2e7 --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/comps/LineChart/types.ts @@ -0,0 +1,57 @@ +import { ScaleLinear, ScaleTime } from 'd3'; +import { FontWeight } from 'react-native-svg'; + +export type TimeSeriesDatum = { date: Date; value: number }; +export type TimeSeries = { + color?: string; + lineWidth?: number; + data: TimeSeriesDatum[]; +}; + +export type AxisOptions = { + enabled?: boolean; + + x?: number; + y?: number; + + lineColor?: string; + lineWidth?: number; + + showTicks?: boolean; + ticks?: Value[] | ((scale: Scale) => Value[]); + + tickLength?: number; + tickWidth?: number; + tickColor?: string; + + tickLabelSize?: number; + tickLabelFont?: string; + tickLabelWeight?: FontWeight; + tickLabelColor?: string; + tickLabelFormatter?: (value: Value) => string; + + showGridLines?: boolean; + gridLineWidth?: number; + gridLineColor?: string; +}; +export type TimeAxisOptions = AxisOptions< + ScaleTime, + Date +>; +export type LinearAxisOptions = AxisOptions< + ScaleLinear, + number +>; + +export type LinesOptions = { + colors?: string[]; + lineWidth?: number; +}; + +export type PaneOptions = { + margin?: number; + marginTop?: number; + marginLeft?: number; + marginRight?: number; + marginBottom?: number; +}; diff --git a/src/screens/C02LineChartWithOptionsScreen/dummySeries.ts b/src/screens/C02LineChartWithOptionsScreen/dummySeries.ts new file mode 100644 index 0000000..6074a1e --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/dummySeries.ts @@ -0,0 +1,52 @@ +import { TimeSeries } from './comps/LineChart/types'; + +const dummyValues = [ + [ + 990810, 921890, 1080000, 857030, 865140, 780000, 978650, 1136757, 1258378, + 1002973, 1286757, 1477297, 1671892, 1469189, 1440811, 1400270, 1538108, + 1371892, 1363784, 1542162, 1497568, 1570541, 1631351, 1542162, 1667838, + 1757027, 1813784, 1854324, 1931352, 1732703, 1744865, 1680000, 1643514, + 1558378, 1400270, 1602973, 1611081, 1546216, 1615135, 1651622, 1700270, + 1838108, 1915136, 1980000, 1793514, 1740811, 1748919, 1882703, 1821892, + 1728649, 1761081, 1631351, 1594865, 1448919, 1388108, 1448919, + ], + [ + 1631351, 1546216, 1440811, 1732703, 1757027, 1980000, 1915136, 1748919, + 1286757, 1728649, 1667838, 1793514, 1002973, 1882703, 1136757, 1761081, + 1615135, 1671892, 1388108, 1631351, 780000, 1542162, 1570541, 1448919, + 1813784, 1538108, 990810, 978650, 921890, 1680000, 1821892, 1080000, + 1558378, 1448919, 1363784, 857030, 1838108, 1651622, 1602973, 1611081, + 1400270, 1700270, 1477297, 1594865, 1744865, 1931352, 1258378, 1854324, + 1643514, 1497568, 1400270, 1469189, 865140, 1371892, 1542162, 1740811, + ], + [ + 780000, 857030, 865140, 921890, 978650, 990810, 1002973, 1080000, 1136757, + 1258378, 1286757, 1363784, 1371892, 1388108, 1400270, 1400270, 1440811, + 1448919, 1448919, 1469189, 1477297, 1497568, 1538108, 1542162, 1542162, + 1546216, 1558378, 1570541, 1594865, 1602973, 1611081, 1615135, 1631351, + 1631351, 1643514, 1651622, 1667838, 1671892, 1680000, 1700270, 1728649, + 1732703, 1740811, 1744865, 1748919, 1757027, 1761081, 1793514, 1813784, + 1821892, 1838108, 1854324, 1882703, 1915136, 1931352, 1980000, + ], + [ + 1980000, 1931352, 1915136, 1882703, 1854324, 1838108, 1821892, 1813784, + 1793514, 1761081, 1757027, 1748919, 1744865, 1740811, 1732703, 1728649, + 1700270, 1680000, 1671892, 1667838, 1651622, 1643514, 1631351, 1631351, + 1615135, 1611081, 1602973, 1594865, 1570541, 1558378, 1546216, 1542162, + 1542162, 1538108, 1497568, 1477297, 1469189, 1448919, 1448919, 1440811, + 1400270, 1400270, 1388108, 1371892, 1363784, 1286757, 1258378, 1136757, + 1080000, 1002973, 990810, 978650, 921890, 865140, 857030, 780000, + ], +]; + +const dummyDate = new Date('2022-01-01'); + +const dummySeries: TimeSeries[] = dummyValues.map(values => ({ + data: values.map((value, i) => { + const date = new Date(dummyDate); + date.setDate(date.getDate() + i); + return { date, value: Math.floor(value / 1000) }; + }), +})); + +export default dummySeries; diff --git a/src/screens/C02LineChartWithOptionsScreen/index.tsx b/src/screens/C02LineChartWithOptionsScreen/index.tsx new file mode 100644 index 0000000..2be8914 --- /dev/null +++ b/src/screens/C02LineChartWithOptionsScreen/index.tsx @@ -0,0 +1,122 @@ +import { ScrollView, StyleSheet, View } from 'react-native'; + +import ScreenContainer from 'components/ScreenContainer'; +import Text from 'components/Text'; + +import LineChart from './comps/LineChart'; +import dummySeries from './dummySeries'; + +const dummySeriesWithCustom = [...dummySeries.map(sr => ({ ...sr }))]; +dummySeriesWithCustom[0].color = 'black'; +dummySeriesWithCustom[0].lineWidth = 4; + +function C02LineChartWithOptionsScreen() { + return ( + + + C02LineChartWithOptionsScreen + - 라인 차트 옵션 기능 구현 + + + + 옵션 적용 안 함 + + + X 축 옵션 적용 + + `${date.getMonth() + 1}.${date.getDate()}`, + }} + /> + + Y 축 옵션 적용 + val.toLocaleString(), + }} + /> + + 그리드라인 옵션 적용 + + + 라인 옵션 적용 + + + series 에 직접 라인 옵션 적용 + + + + ); +} + +const styles = StyleSheet.create({ + header: { + padding: 16, + }, + title: { + fontSize: 20, + }, + desc: {}, + subtitle: { + marginTop: 16, + fontSize: 16, + }, +}); +export default C02LineChartWithOptionsScreen; diff --git a/src/screens/HomeScreen/index.tsx b/src/screens/HomeScreen/index.tsx index b4fbd8b..f9d8a01 100644 --- a/src/screens/HomeScreen/index.tsx +++ b/src/screens/HomeScreen/index.tsx @@ -1,8 +1,19 @@ +import { Fragment } from 'react'; import { StyleSheet, View } from 'react-native'; -import Button from '../../components/Button'; -import ScreenContainer from '../../components/ScreenContainer'; -import Text from '../../components/Text'; -import useNavigation from '../../navigation/useNavigation'; + +import Button from 'components/Button'; +import ScreenContainer from 'components/ScreenContainer'; +import Text from 'components/Text'; +import useNavigation from 'navigation/useNavigation'; +import { RootStackParamsList } from 'navigation/types'; + +const buttons: { title: string; screenName: keyof RootStackParamsList }[] = [ + { title: 'C01 - 라인차트 기본 기능 구현', screenName: 'C01LineChart' }, + { + title: 'C02 - 라인차트 옵션 기능 구현', + screenName: 'C02LineChartWithOptions', + }, +]; function HomeScreen() { const nav = useNavigation(); @@ -11,10 +22,15 @@ function HomeScreen() { 화면 목록 -