diff --git a/jest.config.js b/jest.config.js index d6314ca..c4afed0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { preset: 'react-native', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], transformIgnorePatterns: [ - 'node_modules/(?!(jest-)?@react-native|react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base)', + 'node_modules/(?!(jest-)?@react-native|react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@wuba/react-native-echarts|echarts|zrender)', ], setupFilesAfterEnv: [ '/src/__mocks__/globalMock.js', diff --git a/package.json b/package.json index 88627bf..a607bc7 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "react-native": "^0.71.11", "react-native-ble-manager": "^10.1.5", "react-native-bootsplash": "^4.7.2", - "react-native-charts-wrapper": "^0.6.0", + "@wuba/react-native-echarts": "^3.0.1", + "echarts": "^5.4.3", + "zrender": "^5.4.4", "react-native-default-preference": "^1.4.4", "react-native-fs": "^2.18.0", "react-native-gesture-handler": "^2.9.0", @@ -80,7 +82,6 @@ "@types/lodash": "^4.14.191", "@types/node": "^18.13.0", "@types/react": "^18.0.24", - "@types/react-native-charts-wrapper": "^0.5.3", "@types/react-native-vector-icons": "^6.4.10", "@types/react-test-renderer": "^18.0.0", "@types/uuid": "^8.3.4", diff --git a/src/ui/components/charts/AttemptsChart.tsx b/src/ui/components/charts/AttemptsChart.tsx index 8656193..5eb0dbf 100644 --- a/src/ui/components/charts/AttemptsChart.tsx +++ b/src/ui/components/charts/AttemptsChart.tsx @@ -4,15 +4,21 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -import { View, processColor } from 'react-native'; +import { LayoutChangeEvent, View } from 'react-native'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Attempt } from '../../../lib/stif/wrappers'; import { AttemptAnalytics } from '../../../lib/analytics/AttemptAnalytics'; -import { CombinedChart } from 'react-native-charts-wrapper'; +import { GridComponent, LegendComponent } from 'echarts/components'; +import { LineChart, ScatterChart } from 'echarts/charts'; +import { SVGRenderer, SvgChart } from '@wuba/react-native-echarts'; import ZeroAttemptsPlaceholder from '../attempts/ZeroAttemptsPlaceholder'; +import * as echarts from 'echarts/core'; import { useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; +echarts.use([SVGRenderer, ScatterChart, LineChart, GridComponent, LegendComponent]); + interface AttemptsChartProps { attempts?: Attempt[]; averages?: number[]; @@ -52,100 +58,137 @@ export function AttemptsChart({ }: AttemptsChartProps) { const theme = useTheme(); const { t } = useTranslation(); - const pointColor = processColor(theme.colors.primary.replace('1)', '0.75)')); - const textColor = processColor(theme.colors.onBackground); - return ( - - attempt.duration() / 1000) - .reverse() - .map((d, idx) => ({ - x: idx, - y: d, - })) - .filter(point => point.y !== Infinity), - config: { - scatterShape: 'CIRCLE', - scatterShapeSize: 5, - scatterShapeHoleRadius: 2, - color: pointColor, - valueTextColor: textColor, - }, - }, - ] - : [], - }, - lineData: { - dataSets: [ - { - label: t('analytics.best'), - values: new AttemptAnalytics(attempts).sliding - .best() - .map(([x, y]) => ({ - x: x, - y: y / 1000, - })) - .filter(point => point.y !== Infinity), - config: { - color: processColor('yellow'), - circleColor: processColor('yellow'), - circleRadius: 3, - drawCircleHole: false, - drawValues: false, - dashedLine: { - lineLength: 5, - spaceLength: 5, - }, - }, - }, - ...averages.map((a, idx) => { - return { - label: `Ao${a}`, - values: prepareAverage(a, attempts), - config: { - color: processColor( - AVERAGE_COLORS[idx % AVERAGE_COLORS.length], - ), - lineWidth: 2, - drawCircles: false, - drawValues: false, - }, - }; - }), - ], - }, - }} - legend={{ - form: 'CIRCLE', - textColor: textColor, - fontFamily: 'Rubik', - }} - xAxis={{ - textColor: textColor, - position: 'BOTTOM', - fontFamily: 'Rubik', - }} - yAxis={{ - left: { - textColor: textColor, - fontFamily: 'Rubik', - }, - right: { - enabled: false, + const chartRef = useRef(null); + const chartInstance = useRef(null); + const [{ width, height }, setDimensions] = useState({ width: 0, height: 0 }); + + const primaryColor = theme.colors.primary.replace('1)', '0.75)'); + const textColor = theme.colors.onBackground; + + const scatterData = useMemo( + () => + attempts.length <= MAX_POINTS_PER_SERIES + ? attempts + .map(attempt => attempt.duration() / 1000) + .reverse() + .map((d, idx) => [idx, d] as [number, number]) + .filter(([, y]) => y !== Infinity) + : [], + [attempts], + ); + + const bestData = useMemo( + () => + new AttemptAnalytics(attempts).sliding + .best() + .map(([x, y]) => [x, y / 1000] as [number, number]) + .filter(([, y]) => y !== Infinity), + [attempts], + ); + + const averageData = useMemo( + () => + averages.map(a => ({ + label: `Ao${a}`, + data: prepareAverage(a, attempts).map( + ({ x, y }) => [x, y] as [number, number], + ), + })), + [attempts, averages], + ); + + // Initialize chart when the container dimensions are available. + useEffect(() => { + if (!chartRef.current || width === 0 || height === 0) return; + + chartInstance.current = echarts.init(chartRef.current, null, { + renderer: 'svg', + width, + height, + }); + + return () => { + chartInstance.current?.dispose(); + chartInstance.current = null; + }; + }, [width, height]); + + // Update chart options whenever data or theme changes. + useEffect(() => { + if (!chartInstance.current) return; + + chartInstance.current.setOption({ + legend: { + data: [ + t('analytics.duration'), + t('analytics.best'), + ...averages.map(a => `Ao${a}`), + ], + textStyle: { color: textColor, fontFamily: 'Rubik' }, + icon: 'circle', + }, + xAxis: { + type: 'value', + position: 'bottom', + axisLabel: { color: textColor, fontFamily: 'Rubik' }, + axisLine: { lineStyle: { color: textColor } }, + }, + yAxis: { + type: 'value', + axisLabel: { color: textColor, fontFamily: 'Rubik' }, + axisLine: { lineStyle: { color: textColor } }, + }, + series: [ + { + name: t('analytics.duration'), + type: 'scatter', + data: scatterData, + symbolSize: 5, + itemStyle: { color: primaryColor }, + }, + { + name: t('analytics.best'), + type: 'line', + data: bestData, + lineStyle: { color: 'yellow', type: 'dashed' }, + symbol: 'circle', + symbolSize: 3, + showSymbol: true, + itemStyle: { color: 'yellow' }, + }, + ...averageData.map(({ label, data }, idx) => ({ + name: label, + type: 'line' as const, + data, + lineStyle: { + color: AVERAGE_COLORS[idx % AVERAGE_COLORS.length], + width: 2, }, - }} - drawOrder={['SCATTER', 'LINE']} - chartDescription={{ text: '' }} - /> + showSymbol: false, + itemStyle: { color: AVERAGE_COLORS[idx % AVERAGE_COLORS.length] }, + })), + ], + }); + }, [ + width, + height, + scatterData, + bestData, + averageData, + averages, + primaryColor, + textColor, + t, + ]); + + const handleLayout = (e: LayoutChangeEvent) => { + const { width: w, height: h } = e.nativeEvent.layout; + setDimensions({ width: w, height: h }); + }; + + return ( + + ); } diff --git a/src/ui/components/charts/TPSChart.tsx b/src/ui/components/charts/TPSChart.tsx index e6edbab..a368b6b 100644 --- a/src/ui/components/charts/TPSChart.tsx +++ b/src/ui/components/charts/TPSChart.tsx @@ -4,13 +4,19 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -import { StyleSheet, View, processColor } from 'react-native'; +import { LayoutChangeEvent, View } from 'react-native'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { LineChart } from 'react-native-charts-wrapper'; +import { GridComponent } from 'echarts/components'; +import { LineChart } from 'echarts/charts'; +import { SVGRenderer, SvgChart } from '@wuba/react-native-echarts'; import { STIF } from '../../../lib/stif'; +import * as echarts from 'echarts/core'; import { useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; +echarts.use([SVGRenderer, LineChart, GridComponent]); + interface TPSChartProps { solveReplay: STIF.TimestampedMove[]; duration: number; @@ -26,88 +32,124 @@ export default function TPSChart({ }: TPSChartProps) { const { t } = useTranslation(); const theme = useTheme(); - const tps = windowedTPS(solveReplay, duration); - const maxTPS = Math.max(...tps.map(t => t.tps)); - return ( - - - (null); + const chartInstance = useRef(null); + const [{ width, height }, setDimensions] = useState({ width: 0, height: 0 }); + + const tps = useMemo( + () => windowedTPS(solveReplay, duration), + [solveReplay, duration], + ); + const maxTPS = useMemo( + () => (tps.length > 0 ? Math.max(...tps.map(pt => pt.tps)) : 1), + [tps], + ); + + // Initialize chart when the container dimensions are available. + useEffect(() => { + if (!chartRef.current || width === 0 || height === 0) return; + + chartInstance.current = echarts.init(chartRef.current, null, { + renderer: 'svg', + width, + height, + }); + + return () => { + chartInstance.current?.dispose(); + chartInstance.current = null; + }; + }, [width, height]); + + // Update chart options whenever data or theme changes. + useEffect(() => { + if (!chartInstance.current) return; + + chartInstance.current.setOption({ + backgroundColor: theme.colors.background, + grid: { + top: 10, + bottom: showXAxis ? 40 : 10, + left: 50, + right: 10, + }, + xAxis: { + type: 'value', + show: showXAxis, + min: 0, + axisLabel: { + color: theme.colors.onBackground, + fontFamily: 'Rubik', + }, + axisLine: { + lineStyle: { color: theme.colors.onBackground }, + }, + }, + yAxis: { + type: 'value', + min: 0, + axisLabel: { + color: theme.colors.onBackground, + fontFamily: 'Rubik', + }, + axisLine: { + lineStyle: { color: theme.colors.onBackground }, + }, + splitLine: { + lineStyle: { color: theme.colors.onBackground, opacity: 0.1 }, + }, + }, + series: [ + { + name: t('analytics.tps'), + type: 'line', + smooth: true, + data: [[0, 0], ...tps.map(pt => [pt.t, pt.tps])], + lineStyle: { color: theme.colors.primary }, + itemStyle: { color: theme.colors.primary }, + showSymbol: false, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: theme.colors.primary }, + { offset: 1, color: theme.colors.background }, + ], + global: false, + }, + opacity: 0.4, + }, + }, + ...(atTimestamp !== undefined + ? [ { - label: t('analytics.tps'), - values: [ - { x: 0, y: 0 }, - ...tps.map((tps, i) => ({ y: tps.tps, x: tps.t })), + name: 'position', + type: 'line' as const, + data: [ + [atTimestamp / 1000, 0], + [atTimestamp / 1000, maxTPS], ], - config: { - drawValues: false, - drawCircles: false, - mode: 'HORIZONTAL_BEZIER', - color: processColor(theme.colors.primary), - drawFilled: true, - fillAlpha: 100, - fillGradient: { - colors: [ - processColor(theme.colors.background), - processColor(theme.colors.primary), - ], - positions: [0, 1], - angle: 90, - orientation: 'BOTTOM_TOP', - }, - }, - }, - { - label: t('analytics.tps'), - values: - atTimestamp !== undefined - ? [ - { x: atTimestamp / 1000, y: 0 }, - { x: atTimestamp / 1000, y: maxTPS }, - ] - : [], - config: { - drawValues: false, - drawCircles: false, - mode: 'LINEAR', - color: processColor(theme.colors.secondary), - }, + lineStyle: { color: theme.colors.secondary }, + showSymbol: false, }, - ], - }} - chartBackgroundColor={processColor(theme.colors.background)} - marker={{ - enabled: true, - }} - legend={{ - enabled: false, - form: 'CIRCLE', - textColor: processColor(theme.colors.onBackground), - fontFamily: 'Rubik', - }} - xAxis={{ - enabled: showXAxis, - textColor: processColor(theme.colors.onBackground), - position: 'BOTTOM', - fontFamily: 'Rubik', - axisMinimum: 0, - }} - yAxis={{ - left: { - textColor: processColor(theme.colors.onBackground), - fontFamily: 'Rubik', - axisMinimum: 0, - spaceBottom: 0, - }, - right: { - enabled: false, - }, - }} - chartDescription={{ text: '' }} - /> - + ] + : []), + ], + }); + }, [width, height, tps, maxTPS, atTimestamp, theme, showXAxis, t]); + + const handleLayout = (e: LayoutChangeEvent) => { + const { width: w, height: h } = e.nativeEvent.layout; + setDimensions({ width: w, height: h }); + }; + + return ( + + ); } @@ -128,13 +170,3 @@ function windowedTPS( } return tps; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F5FCFF', - }, - chart: { - flex: 1, - }, -}); diff --git a/src/ui/components/charts/__tests__/__snapshots__/AttemptsChart.test.ts.snap b/src/ui/components/charts/__tests__/__snapshots__/AttemptsChart.test.ts.snap deleted file mode 100644 index b39188f..0000000 --- a/src/ui/components/charts/__tests__/__snapshots__/AttemptsChart.test.ts.snap +++ /dev/null @@ -1,9692 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Attempts Chart [Ao50, Ao100] matches snapshot 1`] = ` - - - - - - - -`; - -exports[`Attempts Chart [Ao100, Ao1000] matches snapshot 1`] = ` - - - - - - - -`; - -exports[`Attempts Chart [Default] matches snapshot 1`] = ` - - - - - - - -`; - -exports[`Attempts Chart [Empty] matches snapshot 1`] = ` - - - - - insights.emptyChartLine1 - - - insights.emptyChartLine2 - - - - -`; - -exports[`Attempts Chart [with DNFs] matches snapshot 1`] = ` - - - - - - - -`; diff --git a/src/ui/components/charts/__tests__/__snapshots__/TPSChart.test.ts.snap b/src/ui/components/charts/__tests__/__snapshots__/TPSChart.test.ts.snap deleted file mode 100644 index a2f3bc1..0000000 --- a/src/ui/components/charts/__tests__/__snapshots__/TPSChart.test.ts.snap +++ /dev/null @@ -1,741 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TPS Chart [2x2x2] matches snapshot 1`] = ` - - - - - - - - - -`; - -exports[`TPS Chart [3x3x3 (Gyro)] matches snapshot 1`] = ` - - - - - - - - - -`; - -exports[`TPS Chart [3x3x3] matches snapshot 1`] = ` - - - - - - - - - -`;