mirror of
https://github.com/RealKai42/qwerty-learner.git
synced 2024-11-25 16:22:53 +08:00
Feature/data analysis page (#545)
Co-authored-by: qintianxing <qintianxing@100.me> Co-authored-by: KaiyiWing <Zhang.kaiyi42@gmail.com>
This commit is contained in:
parent
4b75820298
commit
f772ed7880
@ -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",
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,14 +1,13 @@
|
||||
import style from './index.module.css'
|
||||
import React from 'react'
|
||||
|
||||
export type LoadingProps = { message?: string }
|
||||
|
||||
const Loading: React.FC<LoadingProps> = ({ message }) => {
|
||||
const Loading: React.FC = () => {
|
||||
return (
|
||||
<div className={style.overlay}>
|
||||
<div className={style['child-div']}>
|
||||
<div className={style['lds-dual-ring']}></div>
|
||||
<div>{message ? message : 'Loading'}</div>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#faf9ff;]">
|
||||
<div className="flex flex-col items-center justify-center ">
|
||||
<div
|
||||
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
|
||||
role="status"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
29
src/hooks/useWindowSize.tsx
Normal file
29
src/hooks/useWindowSize.tsx
Normal file
@ -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
|
@ -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 (
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={REACT_APP_DEPLOY_ENV === 'pages' ? '/qwerty-learner' : ''}>
|
||||
<Routes>
|
||||
<Route index element={<TypingPage />} />
|
||||
<Route path="/gallery" element={<GalleryPage />} />
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route index element={<TypingPage />} />
|
||||
<Route path="/gallery" element={<GalleryPage />} />
|
||||
<Route path="/analysis" element={<AnalysisPage />} />
|
||||
<Route path="/*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
57
src/pages/Analysis/components/HeatmapCharts.tsx
Normal file
57
src/pages/Analysis/components/HeatmapCharts.tsx
Normal file
@ -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<HeatmapChartsProps> = ({ data, title }) => {
|
||||
const [isOpenDarkMode] = useAtom(isOpenDarkModeAtom)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="text-center text-xl font-bold text-gray-600 dark:text-white">{title}</div>
|
||||
<ActivityCalendar
|
||||
fontSize={20}
|
||||
blockSize={22}
|
||||
blockRadius={7}
|
||||
style={{
|
||||
padding: '40px 60px 20px 100px',
|
||||
color: isOpenDarkMode ? '#fff' : '#000',
|
||||
}}
|
||||
colorScheme={isOpenDarkMode ? 'dark' : 'light'}
|
||||
data={data}
|
||||
theme={{
|
||||
light: ['#f0f0f0', '#6366f1'],
|
||||
dark: ['hsl(0, 0%, 22%)', '#818cf8'],
|
||||
}}
|
||||
renderBlock={(block, activity) =>
|
||||
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: '多',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ReactTooltip id="react-tooltip" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeatmapCharts
|
87
src/pages/Analysis/components/LineCharts.tsx
Normal file
87
src/pages/Analysis/components/LineCharts.tsx
Normal file
@ -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<LineChartsProps> = ({ data, title, suffix, name }) => {
|
||||
const [isOpenDarkMode] = useAtom(isOpenDarkModeAtom)
|
||||
|
||||
const chartRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-center text-xl font-bold text-gray-600 dark:text-white">{title}</div>
|
||||
<div style={{ width: '100%', height: '100%' }} ref={chartRef} className="line-chart flex-grow"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LineCharts
|
354
src/pages/Analysis/components/purple.json
Normal file
354
src/pages/Analysis/components/purple.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
109
src/pages/Analysis/hooks/useWordStats.ts
Normal file
109
src/pages/Analysis/hooks/useWordStats.ts
Normal file
@ -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<IWordStats>({ 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<IWordStats> {
|
||||
// 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 }
|
||||
}
|
77
src/pages/Analysis/index.tsx
Normal file
77
src/pages/Analysis/index.tsx
Normal file
@ -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 (
|
||||
<Layout>
|
||||
<div className="flex w-full flex-1 flex-col overflow-y-auto pl-20 pr-20 pt-20">
|
||||
<IconX className="absolute right-20 top-10 mr-2 h-7 w-7 cursor-pointer text-gray-400" onClick={onBack} />
|
||||
<ScrollArea.Root className="flex-1 overflow-y-auto">
|
||||
<ScrollArea.Viewport className="h-full w-auto pb-[20rem]">
|
||||
{isEmpty ? (
|
||||
<div className="align-items-center m-4 grid h-80 w-auto place-content-center overflow-hidden rounded-lg shadow-lg dark:bg-gray-600">
|
||||
<div className="text-2xl text-gray-400">暂无练习数据</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mx-4 my-8 h-auto w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
|
||||
<HeatmapCharts title="过去一年练习次数热力图" data={exerciseRecord} />
|
||||
</div>
|
||||
<div className="mx-4 my-8 h-auto w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
|
||||
<HeatmapCharts title="过去一年练习词数热力图" data={wordRecord} />
|
||||
</div>
|
||||
<div className="mx-4 my-8 h-80 w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
|
||||
<LineCharts title="过去一年WPM趋势图" name="WPM" data={wpmRecord} />
|
||||
</div>
|
||||
<div className="mx-4 my-8 h-80 w-auto overflow-hidden rounded-lg p-8 shadow-lg dark:bg-gray-700 dark:bg-opacity-50">
|
||||
<LineCharts title="过去一年正确率趋势图" name="正确率(%)" data={accuracyRecord} suffix="%" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar className="flex touch-none select-none bg-transparent " orientation="vertical"></ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<div className="overflow-y-auto"></div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Analysis
|
24
src/pages/Typing/components/AnalysisButton/index.tsx
Normal file
24
src/pages/Typing/components/AnalysisButton/index.tsx
Normal file
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toAnalysis}
|
||||
className={`flex items-center justify-center rounded p-[2px] text-lg text-indigo-500 outline-none transition-colors duration-300 ease-in-out hover:bg-indigo-400 hover:text-white`}
|
||||
title="查看数据统计"
|
||||
>
|
||||
<ChartPie className="icon" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnalysisButton
|
@ -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 ? <IconLanguage /> : <IconLanguageOff />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip className="h-7 w-7" content="查看数据统计">
|
||||
<AnalysisButton />
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip className="h-7 w-7" content="开关深色模式(Ctrl + D)">
|
||||
<button
|
||||
className={`p-[2px] text-lg text-indigo-500 focus:outline-none`}
|
||||
|
Loading…
Reference in New Issue
Block a user