@@ -4,14 +4,16 @@ import { useMemo } from "react"
44import { ScatterChart , Scatter , XAxis , YAxis , Label , Customized , Cross , LabelList } from "recharts"
55
66import { formatCurrency } from "@/lib"
7- import { ChartContainer , ChartTooltip , ChartTooltipContent , ChartConfig } from "@/components/ui"
7+ import { ChartContainer , ChartTooltip , ChartConfig } from "@/components/ui"
88
99import type { EvalRun } from "./types"
1010
1111type PlotProps = {
1212 tableData : ( EvalRun & { label : string ; cost : number } ) [ ]
1313}
1414
15+ type LabelPosition = "top" | "bottom" | "left" | "right"
16+
1517export const Plot = ( { tableData } : PlotProps ) => {
1618 const chartData = useMemo ( ( ) => tableData . filter ( ( { cost } ) => cost < 50 ) , [ tableData ] )
1719
@@ -20,6 +22,155 @@ export const Plot = ({ tableData }: PlotProps) => {
2022 [ chartData ] ,
2123 )
2224
25+ // Calculate label positions to avoid overlaps.
26+ const labelPositions = useMemo ( ( ) => {
27+ const positions : Record < string , LabelPosition > = { }
28+
29+ // Track placed labels with their approximate bounds.
30+ const placedLabels : Array < {
31+ cost : number
32+ score : number
33+ label : string
34+ position : LabelPosition
35+ } > = [ ]
36+
37+ // Helper function to check if two labels would overlap.
38+ const wouldLabelsOverlap = (
39+ p1 : { cost : number ; score : number ; position : LabelPosition } ,
40+ p2 : { cost : number ; score : number ; position : LabelPosition } ,
41+ ) : boolean => {
42+ // Approximate thresholds for overlap detection.
43+ const horizontalThreshold = 4 // Cost units.
44+ const verticalThreshold = 5 // Score units.
45+
46+ const costDiff = Math . abs ( p1 . cost - p2 . cost )
47+ const scoreDiff = Math . abs ( p1 . score - p2 . score )
48+
49+ // If points are far apart, no overlap.
50+ if ( costDiff > horizontalThreshold * 2 || scoreDiff > verticalThreshold * 2 ) {
51+ return false
52+ }
53+
54+ // Check specific position combinations for overlap.
55+ // Same position for nearby points definitely overlaps.
56+ if ( p1 . position === p2 . position && costDiff < horizontalThreshold && scoreDiff < verticalThreshold ) {
57+ return true
58+ }
59+
60+ // Check adjacent position overlaps.
61+ const p1IsTop = p1 . position === "top"
62+ const p1IsBottom = p1 . position === "bottom"
63+ const p2IsTop = p2 . position === "top"
64+ const p2IsBottom = p2 . position === "bottom"
65+
66+ // If both labels are on the same vertical side and points are close
67+ // horizontally.
68+ if ( ( p1IsTop && p2IsTop ) || ( p1IsBottom && p2IsBottom ) ) {
69+ if ( costDiff < horizontalThreshold && scoreDiff < verticalThreshold / 2 ) {
70+ return true
71+ }
72+ }
73+
74+ return false
75+ }
76+
77+ // Helper function to check if position would overlap with a data point.
78+ const wouldOverlapPoint = ( point : ( typeof chartData ) [ 0 ] , position : LabelPosition ) : boolean => {
79+ for ( const other of chartData ) {
80+ if ( other . label === point . label ) {
81+ continue
82+ }
83+
84+ const costDiff = Math . abs ( point . cost - other . cost )
85+ const scoreDiff = Math . abs ( point . score - other . score )
86+
87+ // Check if label would be placed on top of another point.
88+ switch ( position ) {
89+ case "top" :
90+ // Label is above, check if there's a point above.
91+ if ( costDiff < 3 && other . score > point . score && other . score - point . score < 6 ) {
92+ return true
93+ }
94+ break
95+ case "bottom" :
96+ // Label is below, check if there's a point below.
97+ if ( costDiff < 3 && other . score < point . score && point . score - other . score < 6 ) {
98+ return true
99+ }
100+ break
101+ case "left" :
102+ // Label is to the left, check if there's a point to the left.
103+ if ( scoreDiff < 3 && other . cost < point . cost && point . cost - other . cost < 4 ) {
104+ return true
105+ }
106+ break
107+ case "right" :
108+ // Label is to the right, check if there's a point to the right.
109+ if ( scoreDiff < 3 && other . cost > point . cost && other . cost - point . cost < 4 ) {
110+ return true
111+ }
112+ break
113+ }
114+ }
115+ return false
116+ }
117+
118+ // Sort points to process them in a consistent order.
119+ // Process from top-left to bottom-right.
120+ const sortedData = [ ...chartData ] . sort ( ( a , b ) => {
121+ // First by score (higher first).
122+ const scoreDiff = b . score - a . score
123+ if ( Math . abs ( scoreDiff ) > 1 ) return scoreDiff
124+ // Then by cost (lower first).
125+ return a . cost - b . cost
126+ } )
127+
128+ // Process each point and find the best position.
129+ sortedData . forEach ( ( point ) => {
130+ // Try positions in order of preference.
131+ const positionPreferences : LabelPosition [ ] = [ "top" , "bottom" , "right" , "left" ]
132+
133+ let bestPosition : LabelPosition = "top"
134+
135+ for ( const position of positionPreferences ) {
136+ // Check if this position would overlap with any placed labels.
137+ let hasLabelOverlap = false
138+
139+ for ( const placed of placedLabels ) {
140+ if (
141+ wouldLabelsOverlap (
142+ { cost : point . cost , score : point . score , position } ,
143+ { cost : placed . cost , score : placed . score , position : placed . position } ,
144+ )
145+ ) {
146+ hasLabelOverlap = true
147+ break
148+ }
149+ }
150+
151+ // Check if this position would overlap with any data points.
152+ const hasPointOverlap = wouldOverlapPoint ( point , position )
153+
154+ // If no overlaps, use this position.
155+ if ( ! hasLabelOverlap && ! hasPointOverlap ) {
156+ bestPosition = position
157+ break
158+ }
159+ }
160+
161+ // Use the best position found
162+ positions [ point . label ] = bestPosition
163+ placedLabels . push ( {
164+ cost : point . cost ,
165+ score : point . score ,
166+ label : point . label ,
167+ position : bestPosition ,
168+ } )
169+ } )
170+
171+ return positions
172+ } , [ chartData ] )
173+
23174 return (
24175 < >
25176 < div className = "pb-4 font-medium" > Cost Versus Score</ div >
@@ -47,21 +198,46 @@ export const Plot = ({ tableData }: PlotProps) => {
47198 tickFormatter = { ( value ) => `${ value } %` } >
48199 < Label value = "Score" angle = { - 90 } position = "left" dy = { - 15 } />
49200 </ YAxis >
50- < ChartTooltip content = { < ChartTooltipContent labelKey = "label" hideIndicator /> } />
201+ < ChartTooltip
202+ content = { ( { active, payload } ) => {
203+ if ( ! active || ! payload || ! payload . length || ! payload [ 0 ] ) {
204+ return null
205+ }
206+
207+ const { label, cost, score } = payload [ 0 ] . payload
208+
209+ return (
210+ < div className = "bg-background border rounded-sm p-2 shadow-sm text-left" >
211+ < div className = "border-b pb-1" > { label } </ div >
212+ < div className = "pt-1" >
213+ < div >
214+ Score: < span className = "font-mono" > { Math . round ( score ) } %</ span >
215+ </ div >
216+ < div >
217+ Cost: < span className = "font-mono" > { formatCurrency ( cost ) } </ span >
218+ </ div >
219+ </ div >
220+ </ div >
221+ )
222+ } }
223+ />
51224 < Customized component = { renderQuadrant } />
52225 { chartData . map ( ( d , index ) => (
53226 < Scatter
54227 key = { d . label }
55228 name = { d . label }
56229 data = { [ d ] }
57230 fill = { generateSpectrumColor ( index , chartData . length ) } >
58- < LabelList dataKey = "label" position = "top" offset = { 8 } content = { renderCustomLabel } />
231+ < LabelList
232+ dataKey = "label"
233+ content = { ( props ) => renderCustomLabel ( props , labelPositions [ d . label ] || "top" ) }
234+ />
59235 </ Scatter >
60236 ) ) }
61237 </ ScatterChart >
62238 </ ChartContainer >
63239 < div className = "py-4 text-xs opacity-50" >
64- (Note: Very expensive models are excluded from the scatter plot.)
240+ (Note: Models with a cost of $50 or more are excluded from the scatter plot.)
65241 </ div >
66242 </ >
67243 )
@@ -82,28 +258,59 @@ const renderQuadrant = (props: any) => (
82258)
83259
84260// eslint-disable-next-line @typescript-eslint/no-explicit-any
85- const renderCustomLabel = ( props : any ) => {
261+ const renderCustomLabel = ( props : any , position : LabelPosition ) => {
86262 const { x, y, value } = props
87263 const maxWidth = 80 // Maximum width in pixels - adjust as needed.
88264
89- const truncateText = ( text : string , maxChars : number = 12 ) => {
265+ const truncateText = ( text : string , maxChars : number = 20 ) => {
90266 if ( text . length <= maxChars ) {
91267 return text
92268 }
93269
94270 return text . substring ( 0 , maxChars - 1 ) + "…"
95271 }
96272
273+ // Calculate position offsets based on label position.
274+ let xOffset = 0
275+ let yOffset = 0
276+ let textAnchor : "middle" | "start" | "end" = "middle"
277+ let dominantBaseline : "auto" | "hanging" | "middle" = "auto"
278+
279+ switch ( position ) {
280+ case "top" :
281+ yOffset = - 8
282+ textAnchor = "middle"
283+ dominantBaseline = "auto"
284+ break
285+ case "bottom" :
286+ yOffset = 15
287+ textAnchor = "middle"
288+ dominantBaseline = "hanging"
289+ break
290+ case "left" :
291+ xOffset = - 8
292+ yOffset = 5
293+ textAnchor = "end"
294+ dominantBaseline = "middle"
295+ break
296+ case "right" :
297+ xOffset = 15
298+ yOffset = 5
299+ textAnchor = "start"
300+ dominantBaseline = "middle"
301+ break
302+ }
303+
97304 return (
98305 < text
99- x = { x }
100- y = { y - 5 }
101- fontSize = "10 "
306+ x = { x + xOffset }
307+ y = { y + yOffset }
308+ fontSize = "11 "
102309 fontWeight = "500"
103310 fill = "currentColor"
104311 opacity = "0.8"
105- textAnchor = "middle"
106- dominantBaseline = "auto"
312+ textAnchor = { textAnchor }
313+ dominantBaseline = { dominantBaseline }
107314 style = { {
108315 pointerEvents : "none" ,
109316 maxWidth : `${ maxWidth } px` ,
@@ -117,12 +324,15 @@ const renderCustomLabel = (props: any) => {
117324}
118325
119326const generateSpectrumColor = ( index : number , total : number ) : string => {
120- // Distribute hues evenly across the color wheel (0-360 degrees)
327+ // Distribute hues evenly across the color wheel (0-360 degrees).
121328 // Start at 0 (red) and distribute evenly.
122329 const hue = ( index * 360 ) / total
330+
123331 // Use high saturation for vibrant colors.
124332 const saturation = 70
333+
125334 // Use medium lightness for good visibility on both light and dark backgrounds.
126335 const lightness = 50
336+
127337 return `hsl(${ Math . round ( hue ) } , ${ saturation } %, ${ lightness } %)`
128338}
0 commit comments