|
1 | | -import { HStack, Box } from '@chakra-ui/react'; |
2 | | -import type { SparklinePoint } from '@/api/types'; |
3 | | -import { useEffect, useState, useRef } from 'react'; |
| 1 | +import { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | +import { Box } from "@chakra-ui/react"; |
| 3 | +import type { SparklinePoint } from "@/api/types"; |
4 | 4 |
|
5 | 5 | const TrendBlocks = ({ data }: { data: SparklinePoint[] }) => { |
6 | 6 | const maxBlocks = 20; |
| 7 | + const blockPx = 12; |
| 8 | + const gapPx = 4; |
| 9 | + const totalWidth = maxBlocks * blockPx + (maxBlocks - 1) * gapPx; |
| 10 | + |
7 | 11 | const [isSliding, setIsSliding] = useState(false); |
8 | 12 | const [showNewData, setShowNewData] = useState(false); |
9 | | - const lastTimestampRef = useRef<string | null>(null); |
| 13 | + const lastTsRef = useRef<string | null>(null); |
10 | 14 |
|
11 | | - // Take the last 20 points, but maintain order (oldest to newest) |
12 | | - const recentData = data.slice(-maxBlocks); |
| 15 | + // last up to maxBlocks |
| 16 | + const recent = useMemo( |
| 17 | + () => (Array.isArray(data) ? data.slice(-maxBlocks) : []), |
| 18 | + [data] |
| 19 | + ); |
13 | 20 |
|
14 | | - // Create array of 20 blocks, fill empty ones with null |
15 | | - const displayBlocks = Array(maxBlocks).fill(null).map((_, idx) => { |
16 | | - const dataIdx = idx - (maxBlocks - recentData.length); |
17 | | - return dataIdx >= 0 ? recentData[dataIdx] : null; |
18 | | - }); |
| 21 | + // always exactly maxBlocks long; earlier slots are null placeholders |
| 22 | + const display = useMemo(() => { |
| 23 | + const padded = new Array<SparklinePoint | null>(maxBlocks).fill(null); |
| 24 | + const start = maxBlocks - recent.length; |
| 25 | + for (let i = 0; i < recent.length; i++) { |
| 26 | + padded[start + i] = recent[i]; |
| 27 | + } |
| 28 | + return padded; |
| 29 | + }, [recent]); |
19 | 30 |
|
20 | | - // Detect new data by checking if the latest timestamp changed |
| 31 | + // heartbeat / slide animation trigger when latest timestamp changes |
21 | 32 | useEffect(() => { |
22 | | - const latestTimestamp = recentData.length > 0 ? recentData[recentData.length - 1]?.ts : null; |
23 | | - |
24 | | - if (latestTimestamp && lastTimestampRef.current && latestTimestamp !== lastTimestampRef.current) { |
25 | | - // New data detected - trigger slide animation |
| 33 | + const latest = recent.length ? recent[recent.length - 1].ts : null; |
| 34 | + |
| 35 | + if (latest && lastTsRef.current && latest !== lastTsRef.current) { |
26 | 36 | setIsSliding(true); |
27 | 37 | setShowNewData(false); |
28 | 38 |
|
29 | | - // After slide completes, show the new data |
30 | | - setTimeout(() => { |
| 39 | + const t1 = setTimeout(() => { |
31 | 40 | setShowNewData(true); |
32 | 41 | setIsSliding(false); |
33 | 42 | }, 500); |
34 | 43 |
|
35 | | - // Reset showNewData after slide-in animation completes |
36 | | - setTimeout(() => { |
| 44 | + const t2 = setTimeout(() => { |
37 | 45 | setShowNewData(false); |
38 | 46 | }, 1000); |
39 | | - } else if (latestTimestamp && !lastTimestampRef.current) { |
40 | | - // First load - no animation needed |
| 47 | + |
| 48 | + return () => { |
| 49 | + clearTimeout(t1); |
| 50 | + clearTimeout(t2); |
| 51 | + }; |
| 52 | + } else if (latest && !lastTsRef.current) { |
41 | 53 | setIsSliding(false); |
42 | 54 | setShowNewData(false); |
43 | 55 | } |
44 | 56 |
|
45 | | - lastTimestampRef.current = latestTimestamp; |
46 | | - }, [recentData]); |
| 57 | + lastTsRef.current = latest; |
| 58 | + }, [recent]); |
| 59 | + |
| 60 | + // debug log to help spot if parent is sending too many points |
| 61 | + useEffect(() => { |
| 62 | + // eslint-disable-next-line no-console |
| 63 | + console.debug( |
| 64 | + "[TrendBlocks]", |
| 65 | + "incoming:", |
| 66 | + data.length, |
| 67 | + "recent:", |
| 68 | + recent.length, |
| 69 | + "slots:", |
| 70 | + display.length, |
| 71 | + "lastTs:", |
| 72 | + lastTsRef.current |
| 73 | + ); |
| 74 | + }, [data, recent, display]); |
47 | 75 |
|
48 | 76 | return ( |
49 | | - <> |
| 77 | + <Box |
| 78 | + width={`${totalWidth}px`} |
| 79 | + minWidth={`${totalWidth}px`} |
| 80 | + maxWidth={`${totalWidth}px`} |
| 81 | + display="grid" |
| 82 | + gridTemplateColumns={`repeat(${maxBlocks}, ${blockPx}px)`} |
| 83 | + gap={`${gapPx}px`} |
| 84 | + overflow="hidden" |
| 85 | + py="2px" |
| 86 | + pr="2px" |
| 87 | + alignItems="center" |
| 88 | + boxSizing="border-box" |
| 89 | + > |
50 | 90 | <style> |
51 | 91 | {` |
52 | 92 | @keyframes heartbeat { |
53 | 93 | 0% { transform: scale(0.95); } |
54 | 94 | 50% { transform: scale(1.1); } |
55 | 95 | 100% { transform: scale(0.95); } |
56 | 96 | } |
57 | | - @keyframes slideLeftGroup { |
| 97 | + @keyframes slideLeft { |
58 | 98 | 0% { transform: translateX(0); } |
59 | | - 100% { transform: translateX(-16px); } |
| 99 | + 100% { transform: translateX(-${blockPx + gapPx}px); } |
60 | 100 | } |
61 | | - @keyframes slideInFromRight { |
62 | | - 0% { transform: translateX(16px) scale(0.1); opacity: 0; } |
63 | | - 50% { transform: translateX(8px) scale(0.5); opacity: 0.5; } |
| 101 | + @keyframes slideIn { |
| 102 | + 0% { transform: translateX(${blockPx + gapPx}px) scale(0.1); opacity: 0; } |
| 103 | + 50% { transform: translateX(${Math.round((blockPx + gapPx) / 2)}px) scale(0.5); opacity: 0.5; } |
64 | 104 | 100% { transform: translateX(0) scale(1); opacity: 1; } |
65 | 105 | } |
66 | | - .heartbeat-animation { |
67 | | - animation: heartbeat 1.5s ease-in-out infinite; |
68 | | - } |
69 | | - .slide-left-animation { |
70 | | - animation: slideLeftGroup 500ms ease-out; |
71 | | - } |
72 | | - .slide-in-animation { |
73 | | - animation: slideInFromRight 500ms ease-out; |
74 | | - } |
| 106 | + .hb { animation: heartbeat 1.5s ease-in-out infinite; will-change: transform; } |
| 107 | + .slide-left { animation: slideLeft 500ms ease-out; will-change: transform; } |
| 108 | + .slide-in { animation: slideIn 500ms ease-out; will-change: transform; } |
75 | 109 | `} |
76 | 110 | </style> |
77 | | - <HStack gap={1} alignItems="center" overflow="hidden" py={"2px"} pr={"2px"}> |
78 | | - {displayBlocks.map((point, idx) => { |
79 | | - const isLastElement = idx === displayBlocks.length - 1 && point !== null; |
80 | | - const isEmpty = point === null; |
81 | | - const isNewElement = isLastElement && showNewData; |
82 | | - const shouldHeartbeat = isLastElement && !isSliding; |
83 | 111 |
|
| 112 | + {display.map((pt, i) => { |
| 113 | + const isLast = i === display.length - 1; |
| 114 | + const isNew = isLast && showNewData && pt !== null; |
| 115 | + const shouldHb = isLast && !isSliding && pt !== null; |
| 116 | + const cls = |
| 117 | + isSliding && pt !== null ? "slide-left" : isNew ? "slide-in" : shouldHb ? "hb" : undefined; |
| 118 | + |
| 119 | + const bg = pt === null ? "gray.200" : pt.s === "d" ? "red.500" : "green.500"; |
| 120 | + const darkBg = pt === null ? "gray.700" : pt.s === "d" ? "red.600" : "green.600"; |
84 | 121 |
|
85 | | - return ( |
86 | | - <Box |
87 | | - key={point?.ts || `empty-${idx}`} |
88 | | - w='3' |
89 | | - h='5' |
90 | | - borderRadius='sm' |
91 | | - bg={isEmpty |
92 | | - ? 'gray.200' |
93 | | - : point.s === 'd' ? 'red.500' : 'green.500' |
94 | | - } |
95 | | - _dark={{ |
96 | | - bg: isEmpty |
97 | | - ? 'gray.700' |
98 | | - : point.s === 'd' ? 'red.600' : 'green.600' |
99 | | - }} |
100 | | - className={ |
101 | | - isSliding && !isEmpty |
102 | | - ? 'slide-left-animation' |
103 | | - : isNewElement |
104 | | - ? 'slide-in-animation' |
105 | | - : shouldHeartbeat |
106 | | - ? 'heartbeat-animation' |
107 | | - : undefined |
108 | | - } |
109 | | - transformOrigin="center" |
110 | | - position="relative" |
111 | | - zIndex={isLastElement ? 2 : 1} |
112 | | - /> |
113 | | - ); |
114 | | - })} |
115 | | - </HStack> |
116 | | - </> |
| 122 | + return ( |
| 123 | + <Box |
| 124 | + key={`slot-${i}-${pt?.ts ?? "empty"}`} |
| 125 | + w={`${blockPx}px`} |
| 126 | + h="5" |
| 127 | + borderRadius="sm" |
| 128 | + bg={bg} |
| 129 | + _dark={{ bg: darkBg }} |
| 130 | + className={cls} |
| 131 | + transformOrigin="center" |
| 132 | + position="relative" |
| 133 | + zIndex={isLast && pt ? 2 : 1} |
| 134 | + /> |
| 135 | + ); |
| 136 | + })} |
| 137 | + </Box> |
117 | 138 | ); |
118 | 139 | }; |
119 | 140 |
|
|
0 commit comments