mirror of
https://github.com/RealKai42/qwerty-learner.git
synced 2026-04-04 22:09:04 +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:
@@ -13,9 +13,11 @@
|
|||||||
"@radix-ui/react-slider": "^1.1.1",
|
"@radix-ui/react-slider": "^1.1.1",
|
||||||
"canvas-confetti": "^1.6.0",
|
"canvas-confetti": "^1.6.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
|
"dayjs": "^1.11.8",
|
||||||
"dexie": "^3.2.3",
|
"dexie": "^3.2.3",
|
||||||
"dexie-export-import": "^4.0.7",
|
"dexie-export-import": "^4.0.7",
|
||||||
"dexie-react-hooks": "^1.1.3",
|
"dexie-react-hooks": "^1.1.3",
|
||||||
|
"echarts": "^5.4.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"howler": "^2.2.3",
|
"howler": "^2.2.3",
|
||||||
"html-to-image": "^1.11.11",
|
"html-to-image": "^1.11.11",
|
||||||
@@ -24,11 +26,13 @@
|
|||||||
"mixpanel-browser": "^2.45.0",
|
"mixpanel-browser": "^2.45.0",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-activity-calendar": "^2.0.2",
|
||||||
"react-app-polyfill": "^3.0.0",
|
"react-app-polyfill": "^3.0.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hotkeys-hook": "^4.3.7",
|
"react-hotkeys-hook": "^4.3.7",
|
||||||
"react-router-dom": "^6.8.2",
|
"react-router-dom": "^6.8.2",
|
||||||
"react-timer-hook": "^3.0.5",
|
"react-timer-hook": "^3.0.5",
|
||||||
|
"react-tooltip": "^5.18.0",
|
||||||
"source-map-explorer": "^2.5.2",
|
"source-map-explorer": "^2.5.2",
|
||||||
"swr": "^2.0.4",
|
"swr": "^2.0.4",
|
||||||
"typescript": "^4.0.3",
|
"typescript": "^4.0.3",
|
||||||
@@ -69,6 +73,7 @@
|
|||||||
"@tailwindcss/postcss7-compat": "^2.2.17",
|
"@tailwindcss/postcss7-compat": "^2.2.17",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||||
"@types/canvas-confetti": "^1.6.0",
|
"@types/canvas-confetti": "^1.6.0",
|
||||||
|
"@types/echarts": "^4.9.18",
|
||||||
"@types/file-saver": "^2.0.5",
|
"@types/file-saver": "^2.0.5",
|
||||||
"@types/howler": "^2.2.3",
|
"@types/howler": "^2.2.3",
|
||||||
"@types/mixpanel-browser": "^2.38.1",
|
"@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'
|
import React from 'react'
|
||||||
|
|
||||||
export type LoadingProps = { message?: string }
|
const Loading: React.FC = () => {
|
||||||
|
|
||||||
const Loading: React.FC<LoadingProps> = ({ message }) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={style.overlay}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#faf9ff;]">
|
||||||
<div className={style['child-div']}>
|
<div className="flex flex-col items-center justify-center ">
|
||||||
<div className={style['lds-dual-ring']}></div>
|
<div
|
||||||
<div>{message ? message : 'Loading'}</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>
|
||||||
</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 './index.css'
|
||||||
// import GalleryPage from './pages/Gallery'
|
|
||||||
import GalleryPage from './pages/Gallery-N'
|
|
||||||
import TypingPage from './pages/Typing'
|
import TypingPage from './pages/Typing'
|
||||||
import { isOpenDarkModeAtom } from '@/store'
|
import { isOpenDarkModeAtom } from '@/store'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
import mixpanel from 'mixpanel-browser'
|
import mixpanel from 'mixpanel-browser'
|
||||||
import process from 'process'
|
import process from 'process'
|
||||||
import React, { useEffect } from 'react'
|
import React, { Suspense, lazy, useEffect } from 'react'
|
||||||
import 'react-app-polyfill/stable'
|
import 'react-app-polyfill/stable'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
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') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
// for prod
|
// for prod
|
||||||
mixpanel.init('bdc492847e9340eeebd53cc35f321691')
|
mixpanel.init('bdc492847e9340eeebd53cc35f321691')
|
||||||
@@ -30,11 +32,14 @@ function Root() {
|
|||||||
return (
|
return (
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename={REACT_APP_DEPLOY_ENV === 'pages' ? '/qwerty-learner' : ''}>
|
<BrowserRouter basename={REACT_APP_DEPLOY_ENV === 'pages' ? '/qwerty-learner' : ''}>
|
||||||
<Routes>
|
<Suspense fallback={<Loading />}>
|
||||||
<Route index element={<TypingPage />} />
|
<Routes>
|
||||||
<Route path="/gallery" element={<GalleryPage />} />
|
<Route index element={<TypingPage />} />
|
||||||
<Route path="/*" element={<Navigate to="/" />} />
|
<Route path="/gallery" element={<GalleryPage />} />
|
||||||
</Routes>
|
<Route path="/analysis" element={<AnalysisPage />} />
|
||||||
|
<Route path="/*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</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 { TypingContext, TypingStateActionType } from '../../store'
|
||||||
|
import AnalysisButton from '../AnalysisButton'
|
||||||
import HandPositionIllustration from '../HandPositionIllustration'
|
import HandPositionIllustration from '../HandPositionIllustration'
|
||||||
import LoopWordSwitcher from '../LoopWordSwitcher'
|
import LoopWordSwitcher from '../LoopWordSwitcher'
|
||||||
import Setting from '../Setting'
|
import Setting from '../Setting'
|
||||||
@@ -71,6 +72,11 @@ export default function Switcher() {
|
|||||||
{state?.isTransVisible ? <IconLanguage /> : <IconLanguageOff />}
|
{state?.isTransVisible ? <IconLanguage /> : <IconLanguageOff />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip className="h-7 w-7" content="查看数据统计">
|
||||||
|
<AnalysisButton />
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip className="h-7 w-7" content="开关深色模式(Ctrl + D)">
|
<Tooltip className="h-7 w-7" content="开关深色模式(Ctrl + D)">
|
||||||
<button
|
<button
|
||||||
className={`p-[2px] text-lg text-indigo-500 focus:outline-none`}
|
className={`p-[2px] text-lg text-indigo-500 focus:outline-none`}
|
||||||
|
|||||||
Reference in New Issue
Block a user