@@ -28,25 +28,52 @@ function parseColor(v: string): [number, number, number] {
2828 return m ? [ + m [ 1 ] , + m [ 2 ] , + m [ 3 ] ] : [ 0 , 0 , 0 ] ;
2929}
3030
31- /** 线性插值两个 hex 颜色 */
32- function lerp ( a : [ number , number , number ] , b : [ number , number , number ] , t : number ) {
33- return `rgb(${ Math . round ( a [ 0 ] + ( b [ 0 ] - a [ 0 ] ) * t ) } ,${ Math . round ( a [ 1 ] + ( b [ 1 ] - a [ 1 ] ) * t ) } ,${ Math . round ( a [ 2 ] + ( b [ 2 ] - a [ 2 ] ) * t ) } )` ;
31+ /** RGB 三元组线性插值(供多段混色链使用,避免 string 中间态) */
32+ function mix ( a : [ number , number , number ] , b : [ number , number , number ] , t : number ) : [ number , number , number ] {
33+ return [
34+ Math . round ( a [ 0 ] + ( b [ 0 ] - a [ 0 ] ) * t ) ,
35+ Math . round ( a [ 1 ] + ( b [ 1 ] - a [ 1 ] ) * t ) ,
36+ Math . round ( a [ 2 ] + ( b [ 2 ] - a [ 2 ] ) * t ) ,
37+ ] ;
38+ }
39+
40+ /** RGB 三元组 → CSS 颜色串 */
41+ function toCss ( c : [ number , number , number ] ) : string {
42+ return `rgb(${ c [ 0 ] } ,${ c [ 1 ] } ,${ c [ 2 ] } )` ;
3443}
3544
3645const HeatmapChart : React . FC < Props > = ( { data, loading } ) => {
37- const [ tip , setTip ] = useState < { day : string ; hour : number ; val : number ; x : number ; y : number } | null > ( null ) ;
46+ const [ tip , setTip ] = useState < { day : string ; hour : number ; val : number ; eff : number | null ; peak : boolean ; x : number ; y : number } | null > ( null ) ;
3847
39- const { max, bg, brand } = useMemo ( ( ) => {
48+ const { max, bg, brand, gold } = useMemo ( ( ) => {
4049 const mx = Math . max ( ...data . map ( ( c ) => c . value ) , 1 ) ;
4150 const s = getComputedStyle ( document . documentElement ) ;
42- return { max : mx , bg : parseColor ( s . getPropertyValue ( '--kb-bg-tertiary' ) ) , brand : parseColor ( s . getPropertyValue ( '--kb-brand-500' ) ) } ;
51+ return {
52+ max : mx ,
53+ bg : parseColor ( s . getPropertyValue ( '--kb-bg-tertiary' ) ) ,
54+ brand : parseColor ( s . getPropertyValue ( '--kb-brand-500' ) ) ,
55+ gold : parseColor ( s . getPropertyValue ( '--kb-accent-400' ) || 'rgb(251,191,36)' ) ,
56+ } ;
57+ } , [ data ] ) ;
58+
59+ // D5 效率维度:有效样本的完成率均值(0-1),无样本为 null
60+ const { maxEff, minEff } = useMemo ( ( ) => {
61+ const effs = data . map ( ( c ) => c . efficiency ) . filter ( ( e ) : e is number => e != null ) ;
62+ if ( effs . length === 0 ) return { maxEff : 1 , minEff : 0 } ;
63+ return { maxEff : Math . max ( ...effs , 1 ) , minEff : Math . min ( ...effs , 0 ) } ;
4364 } , [ data ] ) ;
4465
45- const cellColor = useCallback ( ( v : number ) => lerp ( bg , brand , v / max ) , [ bg , brand , max ] ) ;
66+ // D5 单元格配色:亮度=分钟数(原语义),色相=效率(高→品牌金,低→灰蓝)
67+ const cellColor = useCallback ( ( c : HeatmapCell ) => {
68+ const base = mix ( bg , brand , c . value / max ) ;
69+ if ( c . efficiency == null ) return toCss ( base ) ;
70+ const t = maxEff === minEff ? 0.5 : ( c . efficiency - minEff ) / ( maxEff - minEff ) ;
71+ return toCss ( mix ( base , gold , Math . min ( 1 , Math . max ( 0 , t ) ) * 0.55 ) ) ;
72+ } , [ bg , brand , gold , max , maxEff , minEff ] ) ;
4673
4774 const onEnter = useCallback ( ( e : React . MouseEvent , c : HeatmapCell ) => {
4875 const r = ( e . currentTarget as HTMLElement ) . getBoundingClientRect ( ) ;
49- setTip ( { day : DAYS [ c . dayOfWeek ] , hour : c . hour , val : c . value , x : r . left + r . width / 2 , y : r . top } ) ;
76+ setTip ( { day : DAYS [ c . dayOfWeek ] , hour : c . hour , val : c . value , eff : c . efficiency ?? null , peak : c . peak ?? false , x : r . left + r . width / 2 , y : r . top } ) ;
5077 } , [ ] ) ;
5178 const onLeave = useCallback ( ( ) => setTip ( null ) , [ ] ) ;
5279
@@ -75,7 +102,11 @@ const HeatmapChart: React.FC<Props> = ({ data, loading }) => {
75102 < div
76103 key = { `${ c . dayOfWeek } -${ c . hour } ` }
77104 className = "h-[14px] rounded-[2px] cursor-pointer transition-opacity duration-150 hover:opacity-80"
78- style = { { backgroundColor : cellColor ( c . value ) } }
105+ style = { {
106+ backgroundColor : cellColor ( c ) ,
107+ // D5 黄金时段标注:高峰档格子加金色描边,突出个人高效窗口
108+ boxShadow : c . peak && c . value > 0 ? 'inset 0 0 0 1px rgba(251,191,36,0.55)' : undefined ,
109+ } }
79110 onMouseEnter = { ( e ) => onEnter ( e , c ) }
80111 onMouseLeave = { onLeave }
81112 />
@@ -84,6 +115,16 @@ const HeatmapChart: React.FC<Props> = ({ data, loading }) => {
84115 </ div >
85116 </ div >
86117
118+ { /* D5 图例:分钟数 + 黄金时段 */ }
119+ < div className = "mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-text-tertiary" >
120+ < span > 颜色深浅 = 专注分钟数</ span >
121+ < span > 颜色偏金 = 完成率更高</ span >
122+ < span className = "flex items-center gap-1" >
123+ < span className = "inline-block w-3 h-3 rounded-[2px]" style = { { boxShadow : 'inset 0 0 0 1px rgba(251,191,36,0.55)' , backgroundColor : 'rgba(251,191,36,0.15)' } } />
124+ 黄金时段(个人高峰档)
125+ </ span >
126+ </ div >
127+
87128 { /* Tooltip */ }
88129 { tip && (
89130 < div
@@ -92,6 +133,10 @@ const HeatmapChart: React.FC<Props> = ({ data, loading }) => {
92133 >
93134 < span className = "text-text-secondary" > { tip . day } { tip . hour } :00</ span >
94135 < span className = "text-text-primary font-medium ml-1.5" > { tip . val } 分钟</ span >
136+ { tip . eff != null && (
137+ < span className = "text-text-secondary ml-1.5" > 完成率 { Math . round ( tip . eff * 100 ) } %</ span >
138+ ) }
139+ { tip . peak && < span className = "text-amber-400 ml-1.5" > ✦ 黄金时段</ span > }
95140 </ div >
96141 ) }
97142 </ div >
0 commit comments