From f772ed7880cc222674827adf6a5c3b3d46d75b4c Mon Sep 17 00:00:00 2001 From: NoManPlay <39549743+NoManPlay@users.noreply.github.com> Date: Tue, 18 Jul 2023 14:04:36 +0800 Subject: [PATCH] Feature/data analysis page (#545) Co-authored-by: qintianxing Co-authored-by: KaiyiWing --- .yarnrc | 1 + package.json | 5 + src/components/Loading/index.module.css | 46 - src/components/Loading/index.tsx | 15 +- src/hooks/useWindowSize.tsx | 29 + src/index.tsx | 21 +- .../Analysis/components/HeatmapCharts.tsx | 57 + src/pages/Analysis/components/LineCharts.tsx | 87 + src/pages/Analysis/components/purple.json | 354 ++++ src/pages/Analysis/hooks/useWordStats.ts | 109 ++ src/pages/Analysis/index.tsx | 77 + .../components/AnalysisButton/index.tsx | 24 + .../Typing/components/Switcher/index.tsx | 6 + yarn.lock | 1588 +++++++++-------- 14 files changed, 1608 insertions(+), 811 deletions(-) create mode 100644 .yarnrc delete mode 100644 src/components/Loading/index.module.css create mode 100644 src/hooks/useWindowSize.tsx create mode 100644 src/pages/Analysis/components/HeatmapCharts.tsx create mode 100644 src/pages/Analysis/components/LineCharts.tsx create mode 100644 src/pages/Analysis/components/purple.json create mode 100644 src/pages/Analysis/hooks/useWordStats.ts create mode 100644 src/pages/Analysis/index.tsx create mode 100644 src/pages/Typing/components/AnalysisButton/index.tsx diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000..45ef801b --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +registry "https://registry.npmmirror.com" diff --git a/package.json b/package.json index 859ed55c..ed78802a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "@radix-ui/react-slider": "^1.1.1", "canvas-confetti": "^1.6.0", "classnames": "^2.3.2", + "dayjs": "^1.11.8", "dexie": "^3.2.3", "dexie-export-import": "^4.0.7", "dexie-react-hooks": "^1.1.3", + "echarts": "^5.4.2", "file-saver": "^2.0.5", "howler": "^2.2.3", "html-to-image": "^1.11.11", @@ -24,11 +26,13 @@ "mixpanel-browser": "^2.45.0", "pako": "^2.1.0", "react": "^18.2.0", + "react-activity-calendar": "^2.0.2", "react-app-polyfill": "^3.0.0", "react-dom": "^18.2.0", "react-hotkeys-hook": "^4.3.7", "react-router-dom": "^6.8.2", "react-timer-hook": "^3.0.5", + "react-tooltip": "^5.18.0", "source-map-explorer": "^2.5.2", "swr": "^2.0.4", "typescript": "^4.0.3", @@ -69,6 +73,7 @@ "@tailwindcss/postcss7-compat": "^2.2.17", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/canvas-confetti": "^1.6.0", + "@types/echarts": "^4.9.18", "@types/file-saver": "^2.0.5", "@types/howler": "^2.2.3", "@types/mixpanel-browser": "^2.38.1", diff --git a/src/components/Loading/index.module.css b/src/components/Loading/index.module.css deleted file mode 100644 index 14dc8b5e..00000000 --- a/src/components/Loading/index.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.overlay { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 100; - - display: flex; - justify-content: center; - align-items: center; -} - -.child-div { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.lds-dual-ring { - display: inline-block; - width: 80px; - height: 80px; - flex-basis: 100%; -} -.lds-dual-ring:after { - content: ' '; - display: block; - width: 64px; - height: 64px; - margin: 8px; - border-radius: 50%; - border: 6px solid #fff; - border-color: #fff transparent #fff transparent; - animation: lds-dual-ring 1.2s linear infinite; -} -@keyframes lds-dual-ring { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} diff --git a/src/components/Loading/index.tsx b/src/components/Loading/index.tsx index 55bd7d57..21579536 100644 --- a/src/components/Loading/index.tsx +++ b/src/components/Loading/index.tsx @@ -1,14 +1,13 @@ -import style from './index.module.css' import React from 'react' -export type LoadingProps = { message?: string } - -const Loading: React.FC = ({ message }) => { +const Loading: React.FC = () => { return ( -
-
-
-
{message ? message : 'Loading'}
+
+
+
) diff --git a/src/hooks/useWindowSize.tsx b/src/hooks/useWindowSize.tsx new file mode 100644 index 00000000..603ac83c --- /dev/null +++ b/src/hooks/useWindowSize.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react' + +const isClient = typeof window === 'object' + +const useWindowSize = (initialWidth = Infinity, initialHeight = Infinity) => { + const [state, setState] = useState<{ width: number; height: number }>({ + width: isClient ? window.innerWidth : initialWidth, + height: isClient ? window.innerHeight : initialHeight, + }) + + useEffect(() => { + if (isClient) { + const handler = () => { + setState({ + width: window.innerWidth, + height: window.innerHeight, + }) + } + window.addEventListener('resize', handler) + return () => window.removeEventListener('resize', handler) + } else { + return undefined + } + }, []) + + return state +} + +export default useWindowSize diff --git a/src/index.tsx b/src/index.tsx index 4d54b98f..a5fc2b76 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,16 +1,18 @@ +import Loading from './components/Loading' import './index.css' -// import GalleryPage from './pages/Gallery' -import GalleryPage from './pages/Gallery-N' import TypingPage from './pages/Typing' import { isOpenDarkModeAtom } from '@/store' import { useAtomValue } from 'jotai' import mixpanel from 'mixpanel-browser' import process from 'process' -import React, { useEffect } from 'react' +import React, { Suspense, lazy, useEffect } from 'react' import 'react-app-polyfill/stable' import { createRoot } from 'react-dom/client' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +const AnalysisPage = lazy(() => import('./pages/Analysis')) +const GalleryPage = lazy(() => import('./pages/Gallery-N')) + if (process.env.NODE_ENV === 'production') { // for prod mixpanel.init('bdc492847e9340eeebd53cc35f321691') @@ -30,11 +32,14 @@ function Root() { return ( - - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + + ) diff --git a/src/pages/Analysis/components/HeatmapCharts.tsx b/src/pages/Analysis/components/HeatmapCharts.tsx new file mode 100644 index 00000000..4e980419 --- /dev/null +++ b/src/pages/Analysis/components/HeatmapCharts.tsx @@ -0,0 +1,57 @@ +import { isOpenDarkModeAtom } from '@/store' +import { useAtom } from 'jotai' +import type { FC } from 'react' +import React from 'react' +import type { Activity } from 'react-activity-calendar' +import ActivityCalendar from 'react-activity-calendar' +import { Tooltip as ReactTooltip } from 'react-tooltip' +import 'react-tooltip/dist/react-tooltip.css' + +interface HeatmapChartsProps { + title: string + data: Activity[] +} + +const HeatmapCharts: FC = ({ data, title }) => { + const [isOpenDarkMode] = useAtom(isOpenDarkModeAtom) + + return ( +
+
{title}
+ + React.cloneElement(block, { + 'data-tooltip-id': 'react-tooltip', + 'data-tooltip-html': `${activity.date} 练习 ${activity.count} 次`, + }) + } + showWeekdayLabels={true} + labels={{ + months: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'], + weekdays: ['日', '一', '二', '三', '四', '五', '六'], + totalCount: '过去一年总计 {{count}} 次', + legend: { + less: '少', + more: '多', + }, + }} + /> + +
+ ) +} + +export default HeatmapCharts diff --git a/src/pages/Analysis/components/LineCharts.tsx b/src/pages/Analysis/components/LineCharts.tsx new file mode 100644 index 00000000..6de73389 --- /dev/null +++ b/src/pages/Analysis/components/LineCharts.tsx @@ -0,0 +1,87 @@ +import purple from './purple.json' +import useWindowSize from '@/hooks/useWindowSize' +import { isOpenDarkModeAtom } from '@/store' +import { LineChart } from 'echarts/charts' +import { GridComponent, TitleComponent, TooltipComponent } from 'echarts/components' +import * as echarts from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { useAtom } from 'jotai' +import type { FC } from 'react' +import { useEffect, useRef } from 'react' + +echarts.registerTheme('purple', purple) +echarts.use([GridComponent, TitleComponent, TooltipComponent, LineChart, CanvasRenderer]) + +interface LineChartsProps { + title: string + data: [string, number][] + name: string + suffix?: string +} + +const LineCharts: FC = ({ data, title, suffix, name }) => { + const [isOpenDarkMode] = useAtom(isOpenDarkModeAtom) + + const chartRef = useRef(null) + + const { width, height } = useWindowSize() + + useEffect(() => { + if (!chartRef.current || !data.length) return + + let chart = echarts.getInstanceByDom(chartRef.current) + chart?.dispose() + + chart = echarts.init(chartRef.current, isOpenDarkMode ? 'purple' : 'light') + + const option = { + tooltip: { trigger: 'axis' }, + grid: { + left: '10%', + right: '10%', + top: '20%', + bottom: '10%', + }, + xAxis: { + type: 'time', + axisPointer: { + label: { + formatter: function (params: { seriesData: [{ data: [string, number] }] }) { + return params.seriesData[0].data[0] + }, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { formatter: (value: number) => value + (suffix || '') }, + }, + series: [ + { + name, + type: 'line', + smooth: true, + data: data, + emphasis: { focus: 'series' }, + }, + ], + } + + chart.setOption(option) + }, [data, title, suffix, name, isOpenDarkMode]) + + useEffect(() => { + if (!chartRef.current) return + const chart = echarts.getInstanceByDom(chartRef.current) + chart?.resize() + }, [width, height, chartRef]) + + return ( +
+
{title}
+
+
+ ) +} + +export default LineCharts diff --git a/src/pages/Analysis/components/purple.json b/src/pages/Analysis/components/purple.json new file mode 100644 index 00000000..73397f46 --- /dev/null +++ b/src/pages/Analysis/components/purple.json @@ -0,0 +1,354 @@ +{ + "color": ["#9b8bba", "#e098c7", "#8fd3e8", "#71669e", "#cc70af", "#7cb4cc"], + "backgroundColor": "rgba(0,0,0,0)", + "textStyle": {}, + "title": { + "textStyle": { + "color": "#ffffff" + }, + "subtextStyle": { + "color": "#cccccc" + } + }, + "line": { + "itemStyle": { + "borderWidth": "2" + }, + "lineStyle": { + "width": "3" + }, + "symbolSize": "7", + "symbol": "circle", + "smooth": true + }, + "radar": { + "itemStyle": { + "borderWidth": "2" + }, + "lineStyle": { + "width": "3" + }, + "symbolSize": "7", + "symbol": "circle", + "smooth": true + }, + "bar": { + "itemStyle": { + "barBorderWidth": 0, + "barBorderColor": "#ccc" + } + }, + "pie": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "scatter": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "boxplot": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "parallel": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "sankey": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "funnel": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "gauge": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "candlestick": { + "itemStyle": { + "color": "#e098c7", + "color0": "transparent", + "borderColor": "#e098c7", + "borderColor0": "#8fd3e8", + "borderWidth": "2" + } + }, + "graph": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + }, + "lineStyle": { + "width": 1, + "color": "#aaaaaa" + }, + "symbolSize": "7", + "symbol": "circle", + "smooth": true, + "color": ["#9b8bba", "#e098c7", "#8fd3e8", "#71669e", "#cc70af", "#7cb4cc"], + "label": { + "color": "#eeeeee" + } + }, + "map": { + "itemStyle": { + "areaColor": "#eee", + "borderColor": "#444", + "borderWidth": 0.5 + }, + "label": { + "color": "#000" + }, + "emphasis": { + "itemStyle": { + "areaColor": "#e098c7", + "borderColor": "#444", + "borderWidth": 1 + }, + "label": { + "color": "#ffffff" + } + } + }, + "geo": { + "itemStyle": { + "areaColor": "#eee", + "borderColor": "#444", + "borderWidth": 0.5 + }, + "label": { + "color": "#000" + }, + "emphasis": { + "itemStyle": { + "areaColor": "#e098c7", + "borderColor": "#444", + "borderWidth": 1 + }, + "label": { + "color": "#ffffff" + } + } + }, + "categoryAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "color": "#cccccc" + }, + "splitLine": { + "show": false, + "lineStyle": { + "color": ["#eeeeee", "#333333"] + } + }, + "splitArea": { + "show": true, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "valueAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "color": "#cccccc" + }, + "splitLine": { + "show": false, + "lineStyle": { + "color": ["#eeeeee", "#333333"] + } + }, + "splitArea": { + "show": true, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "logAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "color": "#cccccc" + }, + "splitLine": { + "show": false, + "lineStyle": { + "color": ["#eeeeee", "#333333"] + } + }, + "splitArea": { + "show": true, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "timeAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "color": "#cccccc" + }, + "splitLine": { + "show": false, + "lineStyle": { + "color": ["#eeeeee", "#333333"] + } + }, + "splitArea": { + "show": true, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "toolbox": { + "iconStyle": { + "borderColor": "#999999" + }, + "emphasis": { + "iconStyle": { + "borderColor": "#666666" + } + } + }, + "legend": { + "textStyle": { + "color": "#cccccc" + } + }, + "tooltip": { + "axisPointer": { + "lineStyle": { + "color": "#cccccc", + "width": 1 + }, + "crossStyle": { + "color": "#cccccc", + "width": 1 + } + } + }, + "timeline": { + "lineStyle": { + "color": "#8fd3e8", + "width": 1 + }, + "itemStyle": { + "color": "#8fd3e8", + "borderWidth": 1 + }, + "controlStyle": { + "color": "#8fd3e8", + "borderColor": "#8fd3e8", + "borderWidth": 0.5 + }, + "checkpointStyle": { + "color": "#8fd3e8", + "borderColor": "#8a7ca8" + }, + "label": { + "color": "#8fd3e8" + }, + "emphasis": { + "itemStyle": { + "color": "#8fd3e8" + }, + "controlStyle": { + "color": "#8fd3e8", + "borderColor": "#8fd3e8", + "borderWidth": 0.5 + }, + "label": { + "color": "#8fd3e8" + } + } + }, + "visualMap": { + "color": ["#8a7ca8", "#e098c7", "#cceffa"] + }, + "dataZoom": { + "backgroundColor": "rgba(0,0,0,0)", + "dataBackgroundColor": "rgba(255,255,255,0.3)", + "fillerColor": "rgba(167,183,204,0.4)", + "handleColor": "#a7b7cc", + "handleSize": "100%", + "textStyle": { + "color": "#333" + } + }, + "markPoint": { + "label": { + "color": "#eeeeee" + }, + "emphasis": { + "label": { + "color": "#eeeeee" + } + } + } +} diff --git a/src/pages/Analysis/hooks/useWordStats.ts b/src/pages/Analysis/hooks/useWordStats.ts new file mode 100644 index 00000000..9b20b5c0 --- /dev/null +++ b/src/pages/Analysis/hooks/useWordStats.ts @@ -0,0 +1,109 @@ +import { db } from '@/utils/db' +import type { IWordRecord } from '@/utils/db/record' +import dayjs from 'dayjs' +import { useEffect, useState } from 'react' +import type { Activity } from 'react-activity-calendar' + +interface IWordStats { + isEmpty?: boolean + exerciseRecord: Activity[] + wordRecord: Activity[] + wpmRecord: [string, number][] + accuracyRecord: [string, number][] +} + +// 获取两个日期之间的所有日期,使用dayjs计算 +function getDatesBetween(start: number, end: number) { + const dates = [] + let curr = dayjs(start).startOf('day') + const last = dayjs(end).endOf('day') + + while (curr.diff(last) < 0) { + dates.push(curr.clone().format('YYYY-MM-DD')) + curr = curr.add(1, 'day') + } + + return dates +} + +function getLevel(value: number) { + if (value === 0) return 0 + else if (value < 4) return 1 + else if (value < 8) return 2 + else if (value < 12) return 3 + else return 4 +} + +export function useWordStats(startTimeStamp: number, endTimeStamp: number) { + const [wordStats, setWordStats] = useState({ exerciseRecord: [], wordRecord: [], wpmRecord: [], accuracyRecord: [] }) + + useEffect(() => { + const fetchWordStats = async () => { + const stats = await getChapterStats(startTimeStamp, endTimeStamp) + setWordStats(stats) + } + + fetchWordStats() + }, [startTimeStamp, endTimeStamp]) + + return wordStats +} + +async function getChapterStats(startTimeStamp: number, endTimeStamp: number): Promise { + // indexedDB查找某个数字范围内的数据 + const records: IWordRecord[] = await db.wordRecords.where('timeStamp').between(startTimeStamp, endTimeStamp).toArray() + + if (records.length === 0) { + return { isEmpty: true, exerciseRecord: [], wordRecord: [], wpmRecord: [], accuracyRecord: [] } + } + + let data: { + [x: string]: { + exerciseTime: number //练习次数 + words: string[] //练习词数组(不去重) + totalTime: number //总计用时 + wrongCount: number //错误次数 + } + } = {} + + const dates = getDatesBetween(startTimeStamp * 1000, endTimeStamp * 1000) + data = dates + .map((date) => ({ [date]: { exerciseTime: 0, words: [], totalTime: 0, wrongCount: 0 } })) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}) + + for (let i = 0; i < records.length; i++) { + const date = dayjs(records[i].timeStamp * 1000).format('YYYY-MM-DD') + + data[date].exerciseTime = data[date].exerciseTime + 1 + data[date].words = [...data[date].words, records[i].word] + data[date].totalTime = data[date].totalTime + records[i].timing.reduce((acc, curr) => acc + curr, 0) + data[date].wrongCount = data[date].wrongCount + records[i].wrongCount + } + + const RecordArray = Object.entries(data) + + // 练习次数统计 + const exerciseRecord: IWordStats['exerciseRecord'] = RecordArray.map(([date, { exerciseTime }]) => ({ + date, + count: exerciseTime, + level: getLevel(exerciseTime), + })) + // 练习词数统计(去重) + const wordRecord: IWordStats['wordRecord'] = RecordArray.map(([date, { words }]) => ({ + date, + count: Array.from(new Set(words)).length, + level: getLevel(Array.from(new Set(words)).length), + })) + // wpm=练习词数(不去重)/总时间 + const wpmRecord: IWordStats['wpmRecord'] = RecordArray.map<[string, number]>(([date, { words, totalTime }]) => [ + date, + Math.round(words.length / (totalTime / 1000 / 60)), + ]).filter((d) => d[1]) + // 正确率=每个单词的长度合计/(每个单词的长度合计+总错误次数) + const accuracyRecord: IWordStats['accuracyRecord'] = RecordArray.map<[string, number]>(([date, { words, wrongCount }]) => [ + date, + Math.round((words.join('').length / (words.join('').length + wrongCount)) * 100), + ]).filter((d) => d[1]) + + return { exerciseRecord, wordRecord, wpmRecord, accuracyRecord } +} diff --git a/src/pages/Analysis/index.tsx b/src/pages/Analysis/index.tsx new file mode 100644 index 00000000..77d66cbb --- /dev/null +++ b/src/pages/Analysis/index.tsx @@ -0,0 +1,77 @@ +import HeatmapCharts from './components/HeatmapCharts' +import LineCharts from './components/LineCharts' +import { useWordStats } from './hooks/useWordStats' +import Layout from '@/components/Layout' +import { isOpenDarkModeAtom } from '@/store' +import * as ScrollArea from '@radix-ui/react-scroll-area' +import dayjs from 'dayjs' +import { useAtom } from 'jotai' +import { useCallback } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' +import { useNavigate } from 'react-router-dom' +import IconX from '~icons/tabler/x' + +const Analysis = () => { + const navigate = useNavigate() + const [, setIsOpenDarkMode] = useAtom(isOpenDarkModeAtom) + + const onBack = useCallback(() => { + navigate('/') + }, [navigate]) + + const changeDarkModeState = () => { + setIsOpenDarkMode((old) => !old) + } + + useHotkeys( + 'ctrl+d', + () => { + changeDarkModeState() + }, + { enableOnFormTags: true, preventDefault: true }, + [], + ) + + useHotkeys('enter,esc', onBack, { preventDefault: true }) + + const { isEmpty, exerciseRecord, wordRecord, wpmRecord, accuracyRecord } = useWordStats( + dayjs().subtract(1, 'year').unix(), + dayjs().unix(), + ) + + return ( + +
+ + + + {isEmpty ? ( +
+
暂无练习数据
+
+ ) : ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+ + )} +
+ +
+
+
+
+ ) +} + +export default Analysis diff --git a/src/pages/Typing/components/AnalysisButton/index.tsx b/src/pages/Typing/components/AnalysisButton/index.tsx new file mode 100644 index 00000000..5a452734 --- /dev/null +++ b/src/pages/Typing/components/AnalysisButton/index.tsx @@ -0,0 +1,24 @@ +import { useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import ChartPie from '~icons/heroicons/chart-pie-solid' + +const AnalysisButton = () => { + const navigate = useNavigate() + + const toAnalysis = useCallback(() => { + navigate('/analysis') + }, [navigate]) + + return ( + + ) +} + +export default AnalysisButton diff --git a/src/pages/Typing/components/Switcher/index.tsx b/src/pages/Typing/components/Switcher/index.tsx index 69c21831..04cdaea8 100644 --- a/src/pages/Typing/components/Switcher/index.tsx +++ b/src/pages/Typing/components/Switcher/index.tsx @@ -1,4 +1,5 @@ import { TypingContext, TypingStateActionType } from '../../store' +import AnalysisButton from '../AnalysisButton' import HandPositionIllustration from '../HandPositionIllustration' import LoopWordSwitcher from '../LoopWordSwitcher' import Setting from '../Setting' @@ -71,6 +72,11 @@ export default function Switcher() { {state?.isTransVisible ? : } + + + + +