Skip to content

Commit c4ca117

Browse files
committed
feat(chat): add visibility toggle and fix lint error for cost chart
1 parent d990f0e commit c4ca117

File tree

2 files changed

+427
-3
lines changed

2 files changed

+427
-3
lines changed
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import React, { useRef, useEffect, useMemo, useState } from "react" // Added useState
2+
import uPlot, { type Options } from "uplot"
3+
import "uplot/dist/uPlot.min.css" // Import base uPlot CSS
4+
5+
// Define the structure of the input data points
6+
interface CostHistoryDataPoint {
7+
requestIndex: number
8+
cumulativeCost: number // Kept for data structure consistency, but not plotted
9+
costDelta: number // This will be plotted
10+
}
11+
12+
// Define the props for the new component
13+
interface CostTrendChartProps {
14+
data: CostHistoryDataPoint[]
15+
// Callback to notify parent about hover state and data
16+
onHoverChange: (hoverData: { isHovering: boolean; index?: number; cost?: number } | null) => void
17+
}
18+
19+
// Helper function to get computed style with fallback
20+
const getResolvedStyle = (variableName: string, fallback: string): string => {
21+
if (typeof window !== "undefined") {
22+
return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim() || fallback
23+
}
24+
return fallback // Fallback for SSR or testing environments
25+
}
26+
27+
const CostTrendChart: React.FC<CostTrendChartProps> = ({ data, onHoverChange }) => {
28+
const chartRef = useRef<HTMLDivElement>(null)
29+
const uplotInstanceRef = useRef<uPlot | null>(null)
30+
31+
// State to hold resolved VS Code theme colors
32+
const [resolvedStyles, setResolvedStyles] = useState({
33+
foreground: "#cccccc", // Default fallbacks
34+
buttonForeground: "#ffffff",
35+
editorBackground: "#1e1e1e",
36+
tabsBorder: "#555555",
37+
selectionBackground: "rgba(255, 255, 255, 0.2)",
38+
widgetBackground: "#252526",
39+
widgetBorder: "#454545",
40+
descriptionForeground: "#8b949e", // Added for empty state text
41+
fontSize: "13px", // Default font size
42+
fontFamily: "sans-serif", // Default font family
43+
})
44+
45+
// Effect to read styles on mount
46+
useEffect(() => {
47+
setResolvedStyles({
48+
foreground: getResolvedStyle("--vscode-foreground", "#cccccc"),
49+
buttonForeground: getResolvedStyle("--vscode-button-foreground", "#ffffff"),
50+
editorBackground: getResolvedStyle("--vscode-editor-background", "#1e1e1e"),
51+
tabsBorder: getResolvedStyle("--vscode-editorGroupHeader-tabsBorder", "#555555"),
52+
selectionBackground: getResolvedStyle("--vscode-editor-selectionBackground", "rgba(255, 255, 255, 0.2)"),
53+
widgetBackground: getResolvedStyle("--vscode-editorWidget-background", "#252526"),
54+
widgetBorder: getResolvedStyle("--vscode-editorWidget-border", "#454545"),
55+
descriptionForeground: getResolvedStyle("--vscode-descriptionForeground", "#8b949e"),
56+
fontSize: getResolvedStyle("--vscode-font-size", "13px"),
57+
fontFamily: getResolvedStyle("--vscode-font-family", "sans-serif"),
58+
})
59+
// Optional: Add listener for theme changes if available
60+
}, [])
61+
62+
// 1. Transform data for uPlot: [xValues, yValues]
63+
const uplotData = useMemo(() => {
64+
if (!data || data.length === 0) {
65+
return [[], []] // uPlot expects arrays of data for each series
66+
}
67+
68+
// Convert to typed arrays for uPlot
69+
const requestIndices = data.map((d) => d.requestIndex)
70+
const costDeltas = data.map((d) => d.costDelta)
71+
72+
// Cast to any to bypass TypeScript error - uPlot actually accepts regular arrays
73+
return [requestIndices, costDeltas] as any
74+
}, [data])
75+
76+
// 2. Define uPlot Options using resolved styles
77+
const options = useMemo(
78+
(): Options => ({
79+
width: 400, // Initial width, will be updated by resize handler
80+
height: 180, // Increased height to prevent legend overflow
81+
padding: [10, 10, 0, 0], // [top, right, bottom, left] - Minimal padding
82+
series: [
83+
{}, // X-axis series (requestIndex) - Options can be added if needed
84+
{
85+
// Y-axis series (costDelta)
86+
label: "Task Cost", // Legend label
87+
stroke: resolvedStyles.buttonForeground, // Use resolved value
88+
width: 2.5 / (window.devicePixelRatio || 1), // Slightly thicker line for better visibility
89+
points: { show: false }, // Hide points on the line itself
90+
scale: "$", // Link to the '$' scale defined below
91+
},
92+
],
93+
axes: [
94+
{
95+
// X-axis (requestIndex) - Bottom
96+
stroke: resolvedStyles.foreground, // Use resolved value
97+
grid: {
98+
stroke: resolvedStyles.tabsBorder, // Use resolved value
99+
width: 1 / (window.devicePixelRatio || 1),
100+
// alpha: 0.5, // alpha might not be directly supported here, use rgba in stroke if needed
101+
},
102+
ticks: {
103+
stroke: resolvedStyles.tabsBorder, // Changed to tabsBorder for darker grey
104+
width: 1 / (window.devicePixelRatio || 1),
105+
size: 10,
106+
},
107+
font: `${resolvedStyles.fontSize} ${resolvedStyles.fontFamily}`, // Use resolved font size and family
108+
size: 30, // Allocate space for labels if needed, adjust as necessary
109+
// label: "Request Index", // Removed label
110+
// labelSize: 20, // No longer needed
111+
// labelFont: `${resolvedStyles.fontSize} ${resolvedStyles.fontFamily}`, // No longer needed
112+
// Label color is typically inherited from axis stroke or font color setting
113+
// Ensure integer ticks by specifying increments starting with 1
114+
incrs: [1, 2, 5, 10, 20, 50, 100], // Define possible increments for ticks
115+
space: 30, // Minimum space between ticks in pixels, adjust as needed
116+
// Format X-axis ticks as whole numbers (still useful as a fallback)
117+
values: (u: uPlot, ticks: number[]) =>
118+
ticks.map((rawValue: number) => Math.round(rawValue).toString()),
119+
},
120+
{
121+
// Y-axis (costDelta) - Right
122+
scale: "$", // Link to the '$' scale
123+
side: 1, // 1 = right side
124+
// align: 1, // Removed potentially invalid align property
125+
stroke: resolvedStyles.foreground, // Use resolved value
126+
grid: {
127+
stroke: resolvedStyles.tabsBorder, // Use resolved value
128+
width: 1 / (window.devicePixelRatio || 1),
129+
// alpha: 0.5,
130+
},
131+
ticks: {
132+
stroke: resolvedStyles.tabsBorder, // Changed to tabsBorder for darker grey
133+
width: 1 / (window.devicePixelRatio || 1),
134+
size: 10,
135+
},
136+
font: `${resolvedStyles.fontSize} ${resolvedStyles.fontFamily}`, // Use resolved font size and family
137+
// stroke: resolvedStyles.foreground, // This sets the axis line/tick color, text color is often inferred or set by 'font'
138+
// Ensure distinct currency ticks
139+
incrs: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 50, 100], // Define possible increments for currency ticks
140+
space: 30, // Minimum space between ticks in pixels, adjust as needed
141+
// Format Y-axis ticks as currency
142+
values: (u: uPlot, ticks: number[]) => ticks.map((rawValue: number) => `$${rawValue.toFixed(2)}`),
143+
size: 55, // Allocate space for labels like "$1.50" (Keep space for values)
144+
// label: "Cost ($)", // Removed label
145+
// labelSize: 20, // No longer needed without label
146+
// labelFont: `${resolvedStyles.fontSize} ${resolvedStyles.fontFamily}`, // No longer needed without label
147+
// Label color is typically inherited from axis stroke or font color setting
148+
// size: 40, // Removed erroneous duplicate size property
149+
},
150+
],
151+
scales: {
152+
x: {
153+
// Define the scale for the X-axis (requestIndex)
154+
time: false, // Treat x-values as numbers, not timestamps
155+
auto: true, // Automatically determine range (Reverted)
156+
},
157+
$: {
158+
// Define the scale used by the Y-axis series and axis
159+
auto: true, // Automatically determine range based on data
160+
range: [0, null], // Ensure Y-axis starts at 0, max auto-determined
161+
},
162+
},
163+
cursor: {
164+
// Optional: Customize cursor/tooltip behavior
165+
drag: { x: true, y: false, setScale: true }, // Allow horizontal drag-to-zoom
166+
points: {
167+
// Style points shown on hover
168+
show: true,
169+
size: 6 / (window.devicePixelRatio || 1),
170+
stroke: resolvedStyles.buttonForeground, // Use resolved value
171+
fill: resolvedStyles.editorBackground, // Use resolved value
172+
},
173+
focus: {
174+
prox: 30, // Larger proximity for easier interaction
175+
},
176+
// Tooltip customization (basic example)
177+
// You might need a more complex hook for full tooltip parity if required
178+
sync: { key: "cost-chart-sync" }, // Optional: Sync cursor with other charts if needed
179+
},
180+
legend: {
181+
show: false, // Attempt to disable the default tooltip by hiding the legend
182+
// Styling for legend DOM element is handled by injected CSS below
183+
},
184+
hooks: {
185+
// Hook to capture cursor position changes - needs to be an array
186+
setCursor: [
187+
(u: uPlot) => {
188+
const { idx } = u.cursor // idx is the index of the hovered data point
189+
190+
if (idx != null) {
191+
// Cursor is over a data point
192+
const requestIndex = u.data[0]?.[idx] // Use optional chaining
193+
const costDelta = u.data[1]?.[idx] // Use optional chaining
194+
195+
// Ensure data is valid before calling callback
196+
if (typeof requestIndex === "number" && typeof costDelta === "number") {
197+
console.log(`[CostTrendChart Hover] Index: ${requestIndex}, CostDelta: ${costDelta}`) // <-- Add logging
198+
onHoverChange({ isHovering: true, index: requestIndex, cost: costDelta })
199+
} else {
200+
// Data point invalid, treat as not hovering
201+
console.log(`[CostTrendChart Hover] Invalid data at uPlot index: ${idx}`) // <-- Add logging
202+
onHoverChange({ isHovering: false })
203+
}
204+
} else {
205+
// Cursor is not over a data point (or left the plot area)
206+
// console.log("[CostTrendChart Hover] Cursor off point"); // Optional logging
207+
onHoverChange({ isHovering: false })
208+
}
209+
},
210+
],
211+
// Optional: Hook to clear hover state when leaving the plot area
212+
// Using hooks.destroy might be more reliable for cleanup on mouseleave
213+
// destroy: [(u: uPlot) => {
214+
// onHoverChange({ isHovering: false });
215+
// }]
216+
// Note: A more robust 'mouseleave' might require adding event listeners directly to chartRef.current
217+
},
218+
}),
219+
[resolvedStyles, onHoverChange],
220+
) // Recreate options when resolved styles or callback change
221+
222+
// 3. Manage uPlot instance lifecycle and resizing
223+
useEffect(() => {
224+
// console.log("uPlot effect running, data length:", uplotData[0].length);
225+
226+
if (chartRef.current && uplotData[0].length > 0) {
227+
// console.log("Creating uPlot instance with options:", options);
228+
229+
// Destroy previous instance if it exists
230+
uplotInstanceRef.current?.destroy()
231+
232+
try {
233+
// Create new uPlot instance
234+
const uplotInstance = new uPlot(options, uplotData, chartRef.current)
235+
uplotInstanceRef.current = uplotInstance
236+
// console.log("uPlot instance created successfully");
237+
} catch (error) {
238+
console.error("Error creating uPlot instance:", error)
239+
// console.error("Options used:", options);
240+
// console.error("Data used:", uplotData);
241+
}
242+
243+
// Resize handler
244+
const handleResize = () => {
245+
if (chartRef.current && uplotInstanceRef.current) {
246+
uplotInstanceRef.current.setSize({
247+
width: chartRef.current.offsetWidth,
248+
height: options.height!, // Use height from options
249+
})
250+
}
251+
}
252+
253+
// Initial size calculation and event listener setup
254+
handleResize() // Set initial size based on container
255+
window.addEventListener("resize", handleResize)
256+
257+
// Cleanup on component unmount or before re-creation
258+
return () => {
259+
window.removeEventListener("resize", handleResize)
260+
uplotInstanceRef.current?.destroy()
261+
uplotInstanceRef.current = null
262+
}
263+
} else if (uplotInstanceRef.current) {
264+
// If data becomes empty, destroy the existing chart
265+
uplotInstanceRef.current.destroy()
266+
uplotInstanceRef.current = null
267+
}
268+
// Ensure effect re-runs if options or data change
269+
}, [options, uplotData])
270+
271+
// Add custom CSS to override uPlot default styles using resolved colors
272+
// Moved BEFORE the early return to comply with Rules of Hooks
273+
useEffect(() => {
274+
const styleId = "uplot-custom-styles"
275+
// Remove existing style tag if present
276+
document.getElementById(styleId)?.remove()
277+
278+
// Only add styles if the chart is actually going to be rendered
279+
if (uplotData && uplotData[0].length > 0) {
280+
// Add a style tag to the document head
281+
const styleEl = document.createElement("style")
282+
styleEl.id = styleId
283+
styleEl.innerHTML = `
284+
.u-legend {
285+
color: ${resolvedStyles.foreground} !important;
286+
background: ${resolvedStyles.editorBackground} !important;
287+
/* Add some padding/margin if needed, but be mindful of height */
288+
margin-bottom: 5px !important; /* Example margin */
289+
}
290+
.u-legend th, .u-legend td { /* Target table cells too */
291+
color: ${resolvedStyles.foreground} !important;
292+
padding: 2px 5px !important; /* Adjust padding */
293+
}
294+
.u-select {
295+
background: ${resolvedStyles.selectionBackground} !important;
296+
}
297+
.u-tooltip {
298+
background: ${resolvedStyles.widgetBackground} !important;
299+
border: 1px solid ${resolvedStyles.widgetBorder} !important;
300+
color: ${resolvedStyles.foreground} !important;
301+
padding: 4px 8px !important;
302+
border-radius: 3px !important;
303+
font-size: 12px !important;
304+
font-family: var(--vscode-font-family) !important;
305+
z-index: 10 !important; /* Ensure tooltip is above other elements */
306+
}
307+
/* Style legend markers if needed */
308+
.u-legend .u-marker {
309+
border-color: ${resolvedStyles.buttonForeground} !important; /* Example */
310+
}
311+
`
312+
document.head.appendChild(styleEl)
313+
}
314+
315+
// No cleanup needed here as we replace the style tag by ID
316+
}, [resolvedStyles, uplotData]) // Re-apply styles if resolvedStyles or data presence change
317+
318+
// 4. Render the container or empty state message
319+
if (!uplotData || uplotData[0].length === 0) {
320+
// Display a message if there's no data, maintaining the space
321+
return (
322+
<div
323+
className="text-xs p-2 flex items-center justify-center"
324+
style={{
325+
height: `${options.height}px`, // Match chart height from options
326+
width: "100%",
327+
color: resolvedStyles.descriptionForeground, // Use resolved color
328+
}}>
329+
No cost data available yet.
330+
</div>
331+
)
332+
}
333+
334+
// Render the div container for uPlot
335+
// Use height from options object
336+
// Add position: relative to establish positioning context, but remove overflow: hidden
337+
return <div ref={chartRef} style={{ width: "100%", height: `${options.height}px`, position: "relative" }} />
338+
}
339+
340+
export default CostTrendChart

0 commit comments

Comments
 (0)