feat(web-evals): add scatter plot charts (value map + capability timeline)

- Add "Value Map: Salary vs Interview Score" scatter to comparison page
  - Dots colored by tier, sized by success rate
  - Sweet Spot quadrant highlight (upper-left)
  - Respects existing provider/success-rate filters
- Add "AI Coding Capability Over Time" scatter to landing page
  - 10 models from Jun 2025 to Feb 2026
  - Dots colored by provider, sized by cost efficiency
  - Dashed trend line showing upward trajectory
- Add MODEL_TIMELINE data to mock-recommendations.ts
This commit is contained in:
Michael Preuss 2026-02-11 19:53:10 -08:00
parent 336a6726f9
commit bf55aa0103
3 changed files with 509 additions and 2 deletions

View file

@ -15,7 +15,20 @@ import {
Download,
FlaskConical,
} from "lucide-react"
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts"
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Legend,
ScatterChart,
Scatter,
ZAxis,
Cell,
ReferenceArea,
} from "recharts"
import type { ModelCandidate, LanguageScores, EngineerRole, RoleRecommendation } from "@/lib/mock-recommendations"
import { TASKS_PER_DAY } from "@/lib/mock-recommendations"
@ -225,6 +238,20 @@ const DIMENSION_COLORS = {
speed: "#a855f7", // purple
}
const TIER_COLORS: Record<string, string> = {
best: "#22c55e", // green
recommended: "#3b82f6", // blue
situational: "#eab308", // yellow
"not-recommended": "#ef4444", // red
}
const TIER_LABELS: Record<string, string> = {
best: "Best",
recommended: "Recommended",
situational: "Situational",
"not-recommended": "Not Recommended",
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Normalize cost: lower cost → higher bar (0100). */
@ -344,6 +371,62 @@ function CustomTooltip({
)
}
// ── Scatter Tooltip ─────────────────────────────────────────────────────────
function ScatterTooltip({
active,
payload,
}: {
active?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any[]
}) {
if (!active || !payload || !payload.length) return null
const data = payload[0]?.payload as
| {
name?: string
dailyCost?: number
score?: number
successRate?: number
tier?: string
}
| undefined
if (!data) return null
return (
<div className="rounded-xl border border-border/50 bg-card/95 p-4 shadow-2xl backdrop-blur-md">
<p className="mb-2.5 text-sm font-bold tracking-tight">{data.name}</p>
<div className="space-y-1.5">
<div className="flex items-center gap-2.5 text-xs">
<span
className="size-2.5 rounded-full ring-1 ring-white/10"
style={{ backgroundColor: TIER_COLORS[data.tier ?? "situational"] }}
/>
<span className="text-muted-foreground">Tier:</span>
<span className="font-semibold">{TIER_LABELS[data.tier ?? "situational"]}</span>
</div>
<div className="flex items-center gap-2.5 text-xs">
<span className="size-2.5 rounded-full bg-amber-400 ring-1 ring-white/10" />
<span className="text-muted-foreground">Daily Salary:</span>
<span className="font-semibold tabular-nums">${data.dailyCost}/day</span>
</div>
<div className="flex items-center gap-2.5 text-xs">
<span className="size-2.5 rounded-full bg-blue-400 ring-1 ring-white/10" />
<span className="text-muted-foreground">Interview Score:</span>
<span className="font-semibold tabular-nums">{data.score}</span>
</div>
<div className="flex items-center gap-2.5 text-xs">
<span className="size-2.5 rounded-full bg-green-400 ring-1 ring-white/10" />
<span className="text-muted-foreground">Success Rate:</span>
<span className="font-semibold tabular-nums">{data.successRate}%</span>
</div>
</div>
</div>
)
}
// ── Main Component ──────────────────────────────────────────────────────────
interface ComparisonChartProps {
@ -380,6 +463,25 @@ export function ComparisonChart({ recommendation, role, roleId }: ComparisonChar
const chartHeight = Math.max(400, chartData.length * 100)
// Scatter plot data: value map of daily cost vs composite score
const scatterData = useMemo(
() =>
filteredCandidates.map((c) => ({
name: c.displayName,
dailyCost: Math.round(c.estimatedDailyCost),
score: c.compositeScore,
successRate: c.successRate,
tier: c.tier,
// ZAxis size: map success rate to dot size (60400 range)
dotSize: Math.round(60 + (c.successRate / 100) * 340),
})),
[filteredCandidates],
)
// Determine axis domains for scatter plot
const scatterMaxCost = useMemo(() => Math.max(...scatterData.map((d) => d.dailyCost), 10), [scatterData])
const scatterMinScore = useMemo(() => Math.min(...scatterData.map((d) => d.score), 50), [scatterData])
// Providers that actually appear in data
const activeProviders = useMemo(() => {
const providers = new Set(allCandidates.map((c) => c.provider))
@ -624,6 +726,124 @@ export function ComparisonChart({ recommendation, role, roleId }: ComparisonChar
</div>
</motion.section>
{/* ── Value Map Scatter Chart ────────────────────────────── */}
<motion.section
className="rounded-2xl border border-border/50 bg-card/50 p-6 backdrop-blur-sm"
variants={fadeUpVariants}>
<div className="mb-1 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">Value Map: Salary vs Interview Score</h2>
</div>
<p className="mb-4 text-xs leading-relaxed text-muted-foreground/80">
Upper-left = best value. Each dot is a candidate model. Size reflects success rate.
</p>
{/* Tier legend */}
<div className="mb-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{Object.entries(TIER_COLORS).map(([tier, color]) => (
<div key={tier} className="flex items-center gap-1.5">
<span
className="size-2.5 rounded-full ring-1 ring-white/10"
style={{ backgroundColor: color }}
/>
<span>{TIER_LABELS[tier]}</span>
</div>
))}
</div>
{scatterData.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border/50 text-muted-foreground">
<SlidersHorizontal className="size-6 text-muted-foreground/50" />
<p className="text-sm">No candidates match the current filters.</p>
<p className="text-xs text-muted-foreground/60">
Try adjusting the provider or success rate filters.
</p>
</div>
) : (
<div className="rounded-xl bg-background/30 p-2">
<ResponsiveContainer width="100%" height={420}>
<ScatterChart margin={{ top: 20, right: 30, bottom: 20, left: 10 }}>
<XAxis
type="number"
dataKey="dailyCost"
name="Daily Salary"
domain={[0, Math.ceil(scatterMaxCost * 1.1)]}
tickFormatter={(v: number) => `$${v}`}
stroke="hsl(var(--muted-foreground))"
strokeOpacity={0.3}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
label={{
value: "Daily Salary ($)",
position: "insideBottom",
offset: -10,
style: { fontSize: 11, fill: "hsl(var(--muted-foreground))" },
}}
/>
<YAxis
type="number"
dataKey="score"
name="Interview Score"
domain={[Math.max(0, scatterMinScore - 10), 100]}
stroke="hsl(var(--muted-foreground))"
strokeOpacity={0.3}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
label={{
value: "Interview Score",
angle: -90,
position: "insideLeft",
offset: 10,
style: { fontSize: 11, fill: "hsl(var(--muted-foreground))" },
}}
/>
<ZAxis type="number" dataKey="dotSize" range={[60, 400]} />
{/* Sweet spot reference zone: upper-left quadrant */}
<ReferenceArea
x1={0}
x2={Math.ceil(scatterMaxCost * 0.4)}
y1={80}
y2={100}
fill="hsl(var(--foreground))"
fillOpacity={0.03}
stroke="hsl(var(--foreground))"
strokeOpacity={0.08}
strokeDasharray="4 4"
label={{
value: "Sweet Spot",
position: "insideTopLeft",
style: {
fontSize: 10,
fill: "hsl(var(--muted-foreground))",
fontWeight: 500,
},
}}
/>
<Tooltip
content={<ScatterTooltip />}
cursor={{
strokeDasharray: "3 3",
stroke: "hsl(var(--muted-foreground))",
strokeOpacity: 0.3,
}}
/>
<Scatter data={scatterData} name="Candidates">
{scatterData.map((entry, index) => (
<Cell
key={`scatter-cell-${index}`}
fill={TIER_COLORS[entry.tier] ?? "#94a3b8"}
fillOpacity={0.85}
stroke={TIER_COLORS[entry.tier] ?? "#94a3b8"}
strokeWidth={1}
strokeOpacity={0.4}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
)}
</motion.section>
{/* ── Chart Section ──────────────────────────────────────── */}
<motion.section
className="rounded-2xl border border-border/50 bg-card/50 p-6 backdrop-blur-sm"

View file

@ -1,5 +1,6 @@
"use client"
import { useMemo } from "react"
import { motion } from "framer-motion"
import {
Code,
@ -19,9 +20,10 @@ import {
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import Link from "next/link"
import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, Tooltip, ResponsiveContainer, Cell, ReferenceLine } from "recharts"
import type { EngineerRole, RoleRecommendation } from "@/lib/mock-recommendations"
import { TASKS_PER_DAY } from "@/lib/mock-recommendations"
import { TASKS_PER_DAY, MODEL_TIMELINE } from "@/lib/mock-recommendations"
// ── Icon Mapping ────────────────────────────────────────────────────────────
@ -185,6 +187,82 @@ const backgroundVariants = {
},
}
// ── Provider Colors ─────────────────────────────────────────────────────────
const PROVIDER_COLORS: Record<string, string> = {
anthropic: "#fb923c", // orange-400
openai: "#4ade80", // green-400
google: "#60a5fa", // blue-400
xai: "#c084fc", // purple-400
deepseek: "#22d3ee", // cyan-400
moonshot: "#f472b6", // pink-400
}
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
google: "Google",
xai: "xAI",
deepseek: "DeepSeek",
moonshot: "Moonshot",
}
// ── Timeline Tooltip ────────────────────────────────────────────────────────
function TimelineTooltip({
active,
payload,
}: {
active?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any[]
}) {
if (!active || !payload || !payload.length) return null
const data = payload[0]?.payload as
| {
modelName?: string
provider?: string
score?: number
costPerRun?: number
dateLabel?: string
}
| undefined
if (!data) return null
return (
<div className="rounded-xl border border-border/50 bg-card/95 p-4 shadow-2xl backdrop-blur-md">
<p className="mb-2.5 text-sm font-bold tracking-tight">{data.modelName}</p>
<div className="space-y-1.5">
<div className="flex items-center gap-2.5 text-xs">
<span
className="size-2.5 rounded-full ring-1 ring-white/10"
style={{ backgroundColor: PROVIDER_COLORS[data.provider ?? ""] ?? "#94a3b8" }}
/>
<span className="text-muted-foreground">Provider:</span>
<span className="font-semibold">{PROVIDER_DISPLAY[data.provider ?? ""] ?? data.provider}</span>
</div>
<div className="flex items-center gap-2.5 text-xs">
<span className="size-2.5 rounded-full bg-blue-400 ring-1 ring-white/10" />
<span className="text-muted-foreground">Release:</span>
<span className="font-semibold">{data.dateLabel}</span>
</div>
<div className="flex items-center gap-2.5 text-xs">
<span className="size-2.5 rounded-full bg-emerald-400 ring-1 ring-white/10" />
<span className="text-muted-foreground">Eval Score:</span>
<span className="font-semibold tabular-nums">{data.score}%</span>
</div>
<div className="flex items-center gap-2.5 text-xs">
<span className="size-2.5 rounded-full bg-amber-400 ring-1 ring-white/10" />
<span className="text-muted-foreground">Cost per Run:</span>
<span className="font-semibold tabular-nums">${data.costPerRun?.toFixed(2)}</span>
</div>
</div>
</div>
)
}
// ── Sub-Components ──────────────────────────────────────────────────────────
function StatPill({ icon: Icon, value, label }: { icon: LucideIcon; value: string; label: string }) {
@ -218,6 +296,33 @@ export function WorkersContent({
}: WorkersContentProps) {
const recByRole = new Map(recommendations.map((r) => [r.roleId, r]))
// ── Timeline scatter data ──────────────────────────────────────────────
const timelineData = useMemo(() => {
const maxCost = Math.max(...MODEL_TIMELINE.map((m) => m.costPerRun))
return MODEL_TIMELINE.map((m) => {
const date = new Date(m.releaseDate)
return {
modelName: m.modelName,
provider: m.provider,
score: m.score,
costPerRun: m.costPerRun,
// numeric X for scatter: days since epoch
dateNum: date.getTime(),
dateLabel: date.toLocaleDateString("en-US", { month: "short", year: "numeric" }),
// Dot size: inversely proportional to cost (cheaper = bigger dot)
dotSize: Math.round(60 + (1 - m.costPerRun / maxCost) * 340),
}
}).sort((a, b) => a.dateNum - b.dateNum)
}, [])
// Trend line endpoints for the timeline
const trendLine = useMemo(() => {
if (timelineData.length < 2) return null
const first = timelineData[0]!
const last = timelineData[timelineData.length - 1]!
return { x1: first.dateNum, y1: first.score, x2: last.dateNum, y2: last.score }
}, [timelineData])
return (
<>
{/* ── Hero Section ───────────────────────────────────────────── */}
@ -455,6 +560,164 @@ export function WorkersContent({
</div>
</section>
{/* ── AI Coding Capability Over Time ─────────────────────────── */}
<section className="relative overflow-hidden pb-24 pt-8">
{/* Subtle atmospheric background */}
<motion.div
className="absolute inset-0"
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={backgroundVariants}>
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2">
<div className="absolute left-1/2 top-1/2 h-[600px] w-full -translate-x-1/2 -translate-y-1/2 rounded-[100%] bg-emerald-500/5 dark:bg-emerald-600/8 blur-[120px]" />
</div>
</motion.div>
<div className="container relative z-10 mx-auto max-w-screen-lg px-4 sm:px-6 lg:px-8">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={containerVariants}>
{/* Section header */}
<motion.div className="mb-8 text-center" variants={fadeUpVariants}>
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
AI Coding Capability{" "}
<span className="bg-gradient-to-r from-emerald-500 to-blue-500 bg-clip-text text-transparent">
Over Time
</span>
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground md:text-lg">
Pass rates on our eval suite, by model release date. The best ones now score 100%.
</p>
</motion.div>
{/* Chart container */}
<motion.div
className="rounded-2xl border border-border/50 bg-card/50 p-6 backdrop-blur-sm"
variants={fadeUpVariants}>
{/* Provider legend */}
<div className="mb-4 flex flex-wrap items-center justify-center gap-5 text-xs text-muted-foreground">
{Object.entries(PROVIDER_COLORS)
.filter(([provider]) => MODEL_TIMELINE.some((m) => m.provider === provider))
.map(([provider, color]) => (
<div key={provider} className="flex items-center gap-1.5">
<span
className="size-2.5 rounded-full ring-1 ring-white/10"
style={{ backgroundColor: color }}
/>
<span>{PROVIDER_DISPLAY[provider] ?? provider}</span>
</div>
))}
<div className="flex items-center gap-1.5 text-muted-foreground/60">
<span className="text-[10px]"></span>
<span>Bigger dot = lower cost</span>
</div>
</div>
<div className="rounded-xl bg-background/30 p-2">
<ResponsiveContainer width="100%" height={420}>
<ScatterChart margin={{ top: 20, right: 30, bottom: 20, left: 10 }}>
<XAxis
type="number"
dataKey="dateNum"
name="Release Date"
domain={["dataMin", "dataMax"]}
tickFormatter={(v: number) => {
const d = new Date(v)
return d.toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
})
}}
stroke="hsl(var(--muted-foreground))"
strokeOpacity={0.3}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
label={{
value: "Release Date",
position: "insideBottom",
offset: -10,
style: { fontSize: 11, fill: "hsl(var(--muted-foreground))" },
}}
/>
<YAxis
type="number"
dataKey="score"
name="Eval Score"
domain={[85, 102]}
tickFormatter={(v: number) => `${v}%`}
stroke="hsl(var(--muted-foreground))"
strokeOpacity={0.3}
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
label={{
value: "Eval Score (%)",
angle: -90,
position: "insideLeft",
offset: 10,
style: { fontSize: 11, fill: "hsl(var(--muted-foreground))" },
}}
/>
<ZAxis type="number" dataKey="dotSize" range={[60, 400]} />
{/* Trend line: dashed line from first to last */}
{trendLine && (
<ReferenceLine
segment={[
{ x: trendLine.x1, y: trendLine.y1 },
{ x: trendLine.x2, y: trendLine.y2 },
]}
stroke="hsl(var(--muted-foreground))"
strokeOpacity={0.25}
strokeDasharray="6 4"
strokeWidth={1.5}
/>
)}
{/* 100% reference line */}
<ReferenceLine
y={100}
stroke="hsl(var(--muted-foreground))"
strokeOpacity={0.15}
strokeDasharray="3 3"
label={{
value: "Perfect Score",
position: "right",
style: {
fontSize: 10,
fill: "hsl(var(--muted-foreground))",
fillOpacity: 0.5,
},
}}
/>
<Tooltip
content={<TimelineTooltip />}
cursor={{
strokeDasharray: "3 3",
stroke: "hsl(var(--muted-foreground))",
strokeOpacity: 0.3,
}}
/>
<Scatter data={timelineData} name="Models">
{timelineData.map((entry, index) => (
<Cell
key={`timeline-cell-${index}`}
fill={PROVIDER_COLORS[entry.provider] ?? "#94a3b8"}
fillOpacity={0.85}
stroke={PROVIDER_COLORS[entry.provider] ?? "#94a3b8"}
strokeWidth={1}
strokeOpacity={0.4}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
</motion.div>
</motion.div>
</div>
</section>
{/* ── Footer / Methodology Section ───────────────────────────── */}
<section className="relative border-t border-border/50 pb-24 pt-16">
<div className="container mx-auto max-w-screen-lg px-4 sm:px-6 lg:px-8">

View file

@ -864,3 +864,27 @@ export function getCloudSetupUrl(candidate: ModelCandidate): string {
})
return `https://app.roocode.com/sign-up?${params.toString()}`
}
// ── Model Timeline Data ────────────────────────────────────────────────────
// Historical model performance over time for the landing page chart.
export type ModelTimelineEntry = {
modelName: string
provider: string
releaseDate: string // ISO date
score: number // our eval score (total %)
costPerRun: number // total cost for the full eval run
}
export const MODEL_TIMELINE: ModelTimelineEntry[] = [
{ modelName: "Claude 3.5 Sonnet", provider: "anthropic", releaseDate: "2025-06-20", score: 90, costPerRun: 24.98 },
{ modelName: "GPT-4.1", provider: "openai", releaseDate: "2025-08-14", score: 91, costPerRun: 38.64 },
{ modelName: "Claude 3.7 Sonnet", provider: "anthropic", releaseDate: "2025-09-15", score: 95, costPerRun: 37.58 },
{ modelName: "Gemini 2.5 Pro", provider: "google", releaseDate: "2025-10-01", score: 96, costPerRun: 57.8 },
{ modelName: "Claude Sonnet 4", provider: "anthropic", releaseDate: "2025-11-01", score: 98, costPerRun: 39.61 },
{ modelName: "GPT-5 Mini", provider: "openai", releaseDate: "2025-12-01", score: 99, costPerRun: 3.34 },
{ modelName: "Claude Sonnet 4.5", provider: "anthropic", releaseDate: "2026-01-15", score: 100, costPerRun: 38.43 },
{ modelName: "GPT 5.2 (Med)", provider: "openai", releaseDate: "2026-01-20", score: 100, costPerRun: 12.5 },
{ modelName: "Claude Opus 4.6", provider: "anthropic", releaseDate: "2026-02-01", score: 100, costPerRun: 49.48 },
{ modelName: "Gemini 3 Pro", provider: "google", releaseDate: "2026-02-05", score: 100, costPerRun: 33.06 },
]