1+ /**
2+ * Copyright (c) 2022 Hengyang Zhang
3+ *
4+ * This software is released under the MIT License.
5+ * https://opensource.org/licenses/MIT
6+ */
7+
8+ import type { ECharts } from "echarts/core"
9+ import { init , use , ComposeOption } from "echarts/core"
10+ import { HeatmapChart , HeatmapSeriesOption } from "echarts/charts"
11+ import {
12+ TitleComponent , TitleComponentOption ,
13+ TooltipComponent , TooltipComponentOption ,
14+ GridComponent , GridComponentOption ,
15+ VisualMapComponent , VisualMapComponentOption ,
16+ } from "echarts/components"
17+ import { CanvasRenderer } from "echarts/renderers"
18+
19+ // Register echarts
20+ use ( [
21+ CanvasRenderer ,
22+ HeatmapChart ,
23+ TooltipComponent ,
24+ GridComponent ,
25+ VisualMapComponent ,
26+ TitleComponent ,
27+ ] )
28+
29+ import { t } from "@app/locale"
30+ import { MILL_PER_MINUTE } from "@entity/dto/period-info"
31+ import timerService , { TimerQueryParam } from "@service/timer-service"
32+ import { locale } from "@util/i18n"
33+ import { formatTime , getWeeksAgo , MILL_PER_DAY } from "@util/time"
34+ import { ElLoading } from "element-plus"
35+ import { defineComponent , h , onMounted , ref , Ref } from "vue"
36+ import { groupBy , rotate } from "@util/array"
37+ import { BASE_TITLE_OPTION } from "../common"
38+
39+ const WEEK_NUM = 53
40+
41+ const CONTAINER_ID = "__timer_dashboard_heatmap"
42+
43+ type _Value = [
44+ // X
45+ number ,
46+ // Y
47+ number ,
48+ // Value
49+ number ,
50+ // date yyyyMMdd
51+ string ,
52+ ]
53+
54+ type EcOption = ComposeOption <
55+ | HeatmapSeriesOption
56+ | TitleComponentOption
57+ | TooltipComponentOption
58+ | GridComponentOption
59+ | VisualMapComponentOption
60+ >
61+
62+ function formatTooltip ( minutes : number , date : string ) : string {
63+ const hour = Math . floor ( minutes / 60 )
64+ const minute = minutes % 60
65+ const year = date . substr ( 0 , 4 )
66+ const month = date . substr ( 4 , 2 )
67+ const day = date . substr ( 6 , 2 )
68+ const placeholders = {
69+ hour, minute, year, month, day
70+ }
71+
72+ return t (
73+ msg => hour
74+ // With hour
75+ ? msg . dashboard . heatMap . tooltip1
76+ // Without hour
77+ : msg . dashboard . heatMap . tooltip0 ,
78+ placeholders
79+ )
80+ }
81+
82+ function getGridColors ( ) {
83+ return [ 'a' , 'b' , 'c' , 'd' ] . map ( ch => getComputedStyle ( document . body ) . getPropertyValue ( `--timer-dashboard-heatmap-color-${ ch } ` ) )
84+ }
85+
86+ function getXAxisLabelMap ( data : _Value [ ] ) : { [ x : string ] : string } {
87+ const allMonthLabel = t ( msg => msg . calendar . months ) . split ( '|' )
88+ const result = { }
89+ // {[ x:string ]: Set<string> }
90+ const xAndMonthMap = groupBy ( data , e => e [ 0 ] , grouped => new Set ( grouped . map ( a => a [ 3 ] . substr ( 4 , 2 ) ) ) )
91+ let lastMonth = undefined
92+ Object . entries ( xAndMonthMap ) . forEach ( ( [ x , monthSet ] ) => {
93+ if ( monthSet . size != 1 ) {
94+ return
95+ }
96+ const currentMonth = Array . from ( monthSet ) [ 0 ]
97+ if ( currentMonth === lastMonth ) {
98+ return
99+ }
100+ lastMonth = currentMonth
101+ const monthNum = parseInt ( currentMonth )
102+ const label = allMonthLabel [ monthNum - 1 ]
103+ result [ x ] = label
104+ } )
105+ return result
106+ }
107+
108+ function optionOf ( data : _Value [ ] , days : string [ ] ) : EcOption {
109+ const totalMinutes = data . map ( d => d [ 2 ] || 0 ) . reduce ( ( a , b ) => a + b , 0 )
110+ const totalHours = Math . floor ( totalMinutes / 60 )
111+ const xAxisLabelMap = getXAxisLabelMap ( data )
112+ return {
113+ title : {
114+ ...BASE_TITLE_OPTION ,
115+ text : t ( msg => totalHours
116+ ? msg . dashboard . heatMap . title0
117+ : msg . dashboard . heatMap . title1 ,
118+ { hour : totalHours }
119+ )
120+ } ,
121+ tooltip : {
122+ position : 'top' ,
123+ formatter : ( params : any ) => {
124+ const { data } = params
125+ const { value } = data
126+ const [ _1 , _2 , minutes , date ] = value
127+ return minutes ? formatTooltip ( minutes as number , date ) : undefined
128+ }
129+ } ,
130+ grid : { height : '70%' , width : '82%' , left : '8%' , top : '18%' , } ,
131+ xAxis : {
132+ type : 'category' ,
133+ axisLine : { show : false } ,
134+ axisTick : { show : false , alignWithLabel : true } ,
135+ axisLabel : {
136+ formatter : ( x : string ) => xAxisLabelMap [ x ] || '' ,
137+ interval : 0 ,
138+ margin : 14 ,
139+ } ,
140+ } ,
141+ yAxis : {
142+ type : 'category' ,
143+ data : days ,
144+ axisLabel : { padding : /* T R B L */ [ 0 , 12 , 0 , 0 ] } ,
145+ axisLine : { show : false } ,
146+ axisTick : { show : false , alignWithLabel : true }
147+ } ,
148+ visualMap : [ {
149+ min : 1 ,
150+ max : Math . max ( ...data . map ( a => a [ 2 ] ) ) ,
151+ inRange : { color : getGridColors ( ) } ,
152+ realtime : true ,
153+ calculable : true ,
154+ orient : 'vertical' ,
155+ right : '2%' ,
156+ top : 'center' ,
157+ dimension : 2
158+ } ] ,
159+ series : [ {
160+ name : 'Daily Focus' ,
161+ type : 'heatmap' ,
162+ data : data . map ( d => {
163+ let item = { value : d , itemStyle : undefined , label : undefined , emphasis : undefined , tooltip : undefined , silent : false }
164+ const minutes = d [ 2 ]
165+ const date = d [ 3 ]
166+ if ( minutes ) {
167+ } else {
168+ item . itemStyle = {
169+ color : '#fff' ,
170+ }
171+ item . emphasis = {
172+ disabled : true
173+ }
174+ item . silent = true
175+ }
176+ return item
177+ } ) ,
178+ progressive : 5 ,
179+ progressiveThreshold : 10 ,
180+ } ]
181+ }
182+ }
183+
184+ class ChartWrapper {
185+ instance : ECharts
186+ allDates : string [ ]
187+
188+ constructor ( startTime : Date , endTime : Date ) {
189+ let currentTs = startTime . getTime ( )
190+ let maxTs = endTime . getTime ( )
191+ this . allDates = [ ]
192+ for ( ; currentTs < maxTs ; currentTs += MILL_PER_DAY ) {
193+ this . allDates . push ( formatTime ( currentTs , '{y}{m}{d}' ) )
194+ }
195+ }
196+
197+ init ( container : HTMLDivElement ) {
198+ this . instance = init ( container )
199+ }
200+
201+ render ( value : { [ date : string ] : number } , days : string [ ] , loading : { close : ( ) => void } ) {
202+ const data : _Value [ ] = [ ]
203+ this . allDates . forEach ( ( date , index ) => {
204+ const dailyMills = value [ date ] || 0
205+ const dailyMinutes = Math . floor ( dailyMills / MILL_PER_MINUTE )
206+ const colIndex = parseInt ( ( index / 7 ) . toString ( ) )
207+ const weekDay = index % 7
208+ const x = colIndex , y = 7 - ( 1 + weekDay )
209+ data . push ( [ x , y , dailyMinutes , date ] )
210+ } )
211+ const option = optionOf ( data , days )
212+ this . instance . setOption ( option )
213+ loading . close ( )
214+ }
215+ }
216+
217+ const _default = defineComponent ( {
218+ name : "CalendarHeatMap" ,
219+ setup ( ) {
220+ const isChinese = locale === "zh_CN"
221+ const now = new Date ( )
222+ const startTime : Date = getWeeksAgo ( now , isChinese , WEEK_NUM )
223+
224+ const chart : Ref = ref ( )
225+ const chartWrapper : ChartWrapper = new ChartWrapper ( startTime , now )
226+
227+ onMounted ( async ( ) => {
228+ // 1. loading
229+ const loading = ElLoading . service ( {
230+ target : `#${ CONTAINER_ID } ` ,
231+ } )
232+ // 2. init chart
233+ chartWrapper . init ( chart . value )
234+ // 3. query data
235+ const query : TimerQueryParam = { date : [ startTime , now ] , sort : "date" }
236+ const items = await timerService . select ( query )
237+ const result = { }
238+ items . forEach ( ( { date, focus } ) => result [ date ] = ( result [ date ] || 0 ) + focus )
239+ // 4. set weekdays
240+ // Sunday to Monday
241+ const weekDays = ( t ( msg => msg . calendar . weekDays ) ?. split ?.( '|' ) || [ ] ) . reverse ( )
242+ if ( ! isChinese ) {
243+ // Let Sunday last
244+ // Saturday to Sunday
245+ rotate ( weekDays , 1 )
246+ }
247+ // 5. render
248+ chartWrapper . render ( result , weekDays , loading )
249+ } )
250+ return ( ) => h ( 'div' , {
251+ id : CONTAINER_ID ,
252+ class : 'chart-container' ,
253+ ref : chart ,
254+ } )
255+ }
256+ } )
257+
258+ export default _default
0 commit comments