diff --git a/frontend/app/analytics/page.module.css b/frontend/app/analytics/page.module.css new file mode 100644 index 0000000..0661d61 --- /dev/null +++ b/frontend/app/analytics/page.module.css @@ -0,0 +1,23 @@ +.container { + padding: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.heading { + font-size: 24px; + font-weight: 600; + margin-bottom: 16px; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +@media (max-width: 768px) { + .grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/app/analytics/page.tsx b/frontend/app/analytics/page.tsx new file mode 100644 index 0000000..88dc77c --- /dev/null +++ b/frontend/app/analytics/page.tsx @@ -0,0 +1,71 @@ +import { headers } from "next/headers"; +import type { Benchmark } from "../components/BenchmarksTable"; +import FpsByCodecChart from "../components/FpsByCodecChart"; +import VmafHistogram from "../components/VmafHistogram"; +import ScatterFpsSize from "../components/ScatterFpsSize"; +import GroupedSizeByPreset from "../components/GroupedSizeByPreset"; +import styles from "./page.module.css"; + +export const dynamic = "force-dynamic"; + +async function fetchBenchmarks(): Promise { + const internal = process.env.INTERNAL_API_BASE_URL; + + let host = "localhost:3000"; + let proto = "http"; + try { + const h = await headers(); + host = h.get("x-forwarded-host") || h.get("host") || "localhost:3000"; + proto = h.get("x-forwarded-proto") || "http"; + } catch { + // Headers unavailable, use defaults + } + + const origin = `${proto}://${host}`; + const primaryUrl = internal ? `${internal}/query` : `${origin}/api/query`; + try { + const res = await fetch(primaryUrl, { signal: AbortSignal.timeout(10000) }); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + return res.json(); + } catch (err) { + if (internal) { + const res = await fetch(`${origin}/api/query`, { signal: AbortSignal.timeout(10000) }); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + return res.json(); + } + throw err; + } +} + +export default async function AnalyticsPage() { + let data: Benchmark[] = []; + let error: string | null = null; + try { + data = await fetchBenchmarks(); + } catch (e: unknown) { + error = e instanceof Error ? e.message : "Unknown error"; + } + + if (error) { + return ( +
+

Analytics

+
+ Failed to load data: {error} +
+
+ ); + } + + return ( +
+

Analytics

+
+ + + + +
+
+ ); +} diff --git a/frontend/app/components/BenchmarksTable.module.css b/frontend/app/components/BenchmarksTable.module.css index 0181079..b627a95 100644 --- a/frontend/app/components/BenchmarksTable.module.css +++ b/frontend/app/components/BenchmarksTable.module.css @@ -21,6 +21,7 @@ .encoderFilterActive { border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--surface)); } .weightsGrid { @@ -56,7 +57,7 @@ .applyBtn { padding: 6px 10px; background: var(--apply-bg); - color: white; + color: var(--accent-contrast); border-color: var(--apply-border); } diff --git a/frontend/app/components/BenchmarksTable.tsx b/frontend/app/components/BenchmarksTable.tsx index e5647e2..eb20e3c 100644 --- a/frontend/app/components/BenchmarksTable.tsx +++ b/frontend/app/components/BenchmarksTable.tsx @@ -1,7 +1,10 @@ "use client"; -import { useMemo, useState, useEffect, useCallback } from "react"; +import { useMemo, useState, useEffect, useCallback, useRef } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; import styles from "./BenchmarksTable.module.css"; +import ComparePanel, { CompareStickyBar } from "./ComparePanel"; +import { formatCodecLabel } from "./codecLabel"; export type Benchmark = { id: string; @@ -40,15 +43,51 @@ type EnrichedBenchmark = Benchmark & { type SortKey = "cpuModel" | "gpuModel" | "codec" | "crf" | "preset" | "_plove"; export default function BenchmarksTable({ initialData }: { initialData: Benchmark[] }) { - const [cpuFilter, setCpuFilter] = useState(""); - const [gpuFilter, setGpuFilter] = useState(""); - const [codecFilter, setCodecFilter] = useState(""); - const [presetFilter, setPresetFilter] = useState(""); - const [sortKey, setSortKey] = useState("_plove"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const searchParams = useSearchParams(); + const router = useRouter(); + const routerRef = useRef(router); + useEffect(() => { routerRef.current = router; }, [router]); + const isInitRef = useRef(false); + + const [cpuFilter, setCpuFilter] = useState(() => searchParams.get("cpu") || ""); + const [gpuFilter, setGpuFilter] = useState(() => searchParams.get("gpu") || ""); + const [codecFilter, setCodecFilter] = useState(() => searchParams.get("codec") || ""); + const [presetFilter, setPresetFilter] = useState(() => searchParams.get("preset") || ""); + const [sortKey, setSortKey] = useState(() => { + const s = searchParams.get("sort"); + if (s && ["cpuModel", "gpuModel", "codec", "crf", "preset", "_plove"].includes(s)) return s as SortKey; + return "_plove"; + }); + const [sortDir, setSortDir] = useState<"asc" | "desc">(() => { + const d = searchParams.get("dir"); + return d === "asc" ? "asc" : "desc"; + }); // Encoder type filters - const [softwareOnly, setSoftwareOnly] = useState(false); - const [hardwareOnly, setHardwareOnly] = useState(false); + const [softwareOnly, setSoftwareOnly] = useState(() => searchParams.get("sw") === "1"); + const [hardwareOnly, setHardwareOnly] = useState(() => searchParams.get("hw") === "1"); + + // Sync filter state to URL search params (debounced to avoid excessive updates) + const urlDebounceRef = useRef | null>(null); + useEffect(() => { + if (!isInitRef.current) { isInitRef.current = true; return; } + if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); + urlDebounceRef.current = setTimeout(() => { + const params = new URLSearchParams(); + if (cpuFilter) params.set("cpu", cpuFilter); + if (gpuFilter) params.set("gpu", gpuFilter); + if (codecFilter) params.set("codec", codecFilter); + if (presetFilter) params.set("preset", presetFilter); + if (sortKey !== "_plove") params.set("sort", sortKey); + if (sortDir !== "desc") params.set("dir", sortDir); + if (softwareOnly) params.set("sw", "1"); + if (hardwareOnly) params.set("hw", "1"); + const qs = params.toString(); + const base = typeof window !== "undefined" ? window.location.pathname : "/"; + routerRef.current.replace(qs ? `${base}?${qs}` : base, { scroll: false }); + }, 300); + return () => { if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cpuFilter, gpuFilter, codecFilter, presetFilter, sortKey, sortDir, softwareOnly, hardwareOnly]); // Weights for PLOVE score (sum must equal 1.0) const [wQuality, setWQuality] = useState(1 / 3); @@ -70,9 +109,22 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar }, []); const [showDetailId, setShowDetailId] = useState(null); const [showFfmpegId, setShowFfmpegId] = useState(null); + // Compare mode + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showCompare, setShowCompare] = useState(false); + + const toggleSelect = useCallback((id: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { next.delete(id); } else if (next.size < 6) { next.add(id); } + return next; + }); + }, []); + const clearSelection = useCallback(() => { setSelectedIds(new Set()); setShowCompare(false); }, []); const codecs = useMemo(() => Array.from(new Set(initialData.map(d => d.codec))).sort(), [initialData]); const presets = useMemo(() => Array.from(new Set(initialData.map(d => d.preset))).sort(), [initialData]); + const filteredPresets = useMemo(() => codecFilter ? presetsForCodec(initialData, codecFilter) : presets, [initialData, codecFilter, presets]); // Pre-compute hardware encoder classification once per row to avoid repeated regex tests const dataWithHwClass = useMemo(() => { @@ -89,7 +141,7 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar if (cpu && !row.cpuModel.toLowerCase().includes(cpu)) return false; if (gpu && !(row.gpuModel ?? "").toLowerCase().includes(gpu)) return false; if (codecFilter && row.codec !== codecFilter) return false; - if (presetFilter && row.preset !== presetFilter) return false; + if (codecFilter && presetFilter && row.preset !== presetFilter) return false; if (softwareOnly && !hardwareOnly) return !row._isHardware; if (hardwareOnly && !softwareOnly) return row._isHardware; return true; @@ -109,12 +161,10 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar const vmafVals = filtered.filter(r => typeof r.vmaf === "number").map(r => Number(r.vmaf)); const fpsVals = filtered.map(r => Math.max(0, r.fps || 0)); const relSizes = filtered.map(r => (r.fileSizeBytes > 0 ? r.fileSizeBytes / sizeBaseline : 1)); - const vmafMin = vmafVals.length ? Math.min(...vmafVals) : 0; - const vmafMax = vmafVals.length ? Math.max(...vmafVals) : 0; - const fpsMin = fpsVals.length ? Math.min(...fpsVals) : 0; - const fpsMax = fpsVals.length ? Math.max(...fpsVals) : 0; - const rsMin = relSizes.length ? Math.min(...relSizes) : 0; - const rsMax = relSizes.length ? Math.max(...relSizes) : 0; + let vmafMin = 0, vmafMax = 0, fpsMin = 0, fpsMax = 0, rsMin = 0, rsMax = 0; + if (vmafVals.length) { vmafMin = vmafVals[0]; vmafMax = vmafVals[0]; for (const v of vmafVals) { if (v < vmafMin) vmafMin = v; if (v > vmafMax) vmafMax = v; } } + if (fpsVals.length) { fpsMin = fpsVals[0]; fpsMax = fpsVals[0]; for (const v of fpsVals) { if (v < fpsMin) fpsMin = v; if (v > fpsMax) fpsMax = v; } } + if (relSizes.length) { rsMin = relSizes[0]; rsMax = relSizes[0]; for (const v of relSizes) { if (v < rsMin) rsMin = v; if (v > rsMax) rsMax = v; } } return { vmafMin, vmafMax, fpsMin, fpsMax, rsMin, rsMax }; }, [filtered, sizeBaseline]); @@ -181,6 +231,8 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar return data; }, [withScores, sortKey, sortDir]); + const compareRows = useMemo(() => sorted.filter(r => selectedIds.has(r.id)), [sorted, selectedIds]); + const setSort = (key: SortKey) => { if (key === sortKey) setSortDir(d => (d === "asc" ? "desc" : "asc")); else { setSortKey(key); setSortDir("desc"); } @@ -223,7 +275,7 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar title={!codecFilter ? "Select a codec first" : undefined} > - {(codecFilter ? presetsForCodec(initialData, codecFilter) : presets).map(p => ())} + {filteredPresets.map(p => ())} @@ -258,20 +310,22 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar {(() => { const cols = [ - , - , - , - , - , - , + , + , + , + , + , + , + , , - , - , + , + , ]; return {cols}; })()} + {sorted.map(row => ( - + + - @@ -329,12 +393,25 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar setShowFfmpegId(null)} /> ) : null; })()} + + setShowCompare(true)} + onClear={clearSelection} + /> + + {showCompare && selectedIds.size >= 2 && ( + setShowCompare(false)} + onClear={clearSelection} + /> + )} ); } function Th({ label, onClick, active, dir, align }: { label: string; onClick: () => void; active: boolean; dir: "asc" | "desc"; align?: "left" | "right" }) { - const sortLabel = active ? (dir === "asc" ? "sorted ascending" : "sorted descending") : "sortable"; return ( @@ -484,22 +561,6 @@ function FfmpegModal({ row, onClose }: { row: EnrichedBenchmark; onClose: () => ); } -function formatCodecLabel(encoderLower: string): string { - const suffix = (name: string) => { - if (name.endsWith("_videotoolbox")) return " VideoToolbox"; - if (name.endsWith("_nvenc")) return " NVENC"; - if (name.endsWith("_qsv")) return " QSV"; - if (name.endsWith("_amf")) return " AMF"; - if (name.endsWith("_vaapi")) return " VAAPI"; - return ""; - }; - const suf = suffix(encoderLower); - if (encoderLower.includes("av1")) return `AV1${suf}`.trim(); - if (encoderLower.includes("hevc") || encoderLower.includes("h265") || encoderLower.includes("x265")) return `HEVC (H.265)${suf}`.trim(); - if (encoderLower.includes("h264") || encoderLower.includes("x264") || encoderLower.includes("avc")) return `H.264${suf}`.trim(); - if (encoderLower.includes("vp9") || encoderLower.includes("libvpx")) return `VP9${suf}`.trim(); - return encoderLower; -} function isHardwareEncoder(encoderLower: string): boolean { return /(_videotoolbox|_nvenc|_qsv|_amf|_vaapi)$/.test(encoderLower); @@ -559,6 +620,7 @@ function WeightSlider({ label, value, onChange }: { label: string; value: number step={0.01} value={value} onChange={e => onChange(Number(e.target.value))} + style={{ accentColor: "var(--accent)" }} /> ); diff --git a/frontend/app/components/ComparePanel.module.css b/frontend/app/components/ComparePanel.module.css new file mode 100644 index 0000000..46f3d7d --- /dev/null +++ b/frontend/app/components/ComparePanel.module.css @@ -0,0 +1,108 @@ +.stickyBar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--surface); + border-top: 2px solid var(--accent); + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + z-index: 30; + box-shadow: 0 -4px 12px rgba(0,0,0,0.12); +} + +.compareBtn { + padding: 8px 16px; + background: var(--accent); + color: var(--accent-contrast); + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 150ms ease; +} + +.compareBtn:hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.15); +} + +.compareBtn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.clearBtn { + padding: 8px 12px; + cursor: pointer; +} + +.panelBackdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 60; +} + +.panel { + background: var(--surface); + color: var(--foreground); + border: 1px solid var(--border); + border-radius: 12px; + width: 100%; + max-width: 900px; + max-height: 80vh; + overflow: auto; +} + +.panelHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + border-top: 3px solid var(--accent); + position: sticky; + top: 0; + background: var(--surface); + z-index: 1; +} + +.panelTitle { + font-weight: 600; +} + +.panelBody { + padding: 16px; + overflow-x: auto; +} + +.compareTable { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.compareTable th, +.compareTable td { + padding: 8px 12px; + border-top: 1px solid var(--border); + text-align: left; +} + +.compareTable th { + background: var(--surface-2); + font-weight: 600; + white-space: nowrap; +} + +.bestCell { + background: color-mix(in srgb, var(--success-bg) 30%, var(--surface)); +} diff --git a/frontend/app/components/ComparePanel.tsx b/frontend/app/components/ComparePanel.tsx new file mode 100644 index 0000000..8588641 --- /dev/null +++ b/frontend/app/components/ComparePanel.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type { Benchmark } from "./BenchmarksTable"; +import styles from "./ComparePanel.module.css"; + +type CompareRow = Benchmark & { _plove: number; _relSize: number; _codecLabel: string }; + +type Metric = { + label: string; + getValue: (row: CompareRow) => string; + getNumeric: (row: CompareRow) => number | null; + higherIsBetter: boolean; +}; + +const METRICS: Metric[] = [ + { label: "CPU", getValue: r => r.cpuModel, getNumeric: () => null, higherIsBetter: true }, + { label: "GPU", getValue: r => r.gpuModel || "-", getNumeric: () => null, higherIsBetter: true }, + { label: "Codec", getValue: r => r._codecLabel, getNumeric: () => null, higherIsBetter: true }, + { label: "CRF", getValue: r => r.crf == null ? "-" : String(r.crf), getNumeric: r => r.crf ?? null, higherIsBetter: false }, + { label: "Preset", getValue: r => r.preset, getNumeric: () => null, higherIsBetter: true }, + { label: "FPS", getValue: r => r.fps.toFixed(2), getNumeric: r => r.fps, higherIsBetter: true }, + { label: "VMAF", getValue: r => r.vmaf == null ? "-" : r.vmaf.toFixed(1), getNumeric: r => r.vmaf, higherIsBetter: true }, + { label: "File Size (MB)", getValue: r => (r.fileSizeBytes / (1024 * 1024)).toFixed(2), getNumeric: r => r.fileSizeBytes, higherIsBetter: false }, + { label: "PLOVE Score", getValue: r => r._plove > 0 ? r._plove.toFixed(2) : "-", getNumeric: r => r._plove > 0 ? r._plove : null, higherIsBetter: true }, +]; + +function findBestIndex(rows: CompareRow[], metric: Metric): number | null { + let bestIdx: number | null = null; + let bestVal: number | null = null; + for (let i = 0; i < rows.length; i++) { + const v = metric.getNumeric(rows[i]); + if (v == null) continue; + if (bestVal == null || (metric.higherIsBetter ? v > bestVal : v < bestVal)) { + bestVal = v; + bestIdx = i; + } + } + // Don't highlight if all values are the same + if (bestIdx !== null) { + const vals = rows.map(r => metric.getNumeric(r)).filter(v => v != null); + if (vals.length > 0 && vals.every(v => v === vals[0])) return null; + } + return bestIdx; +} + +export default function ComparePanel({ + rows, + onClose, + onClear, +}: { + rows: CompareRow[]; + onClose: () => void; + onClear: () => void; +}) { + return ( +
+
+
+
+ Comparing {rows.length} Benchmarks +
+
+ + +
+
+
+
Details setSort("cpuModel")} label="CPU" active={sortKey === "cpuModel"} dir={sortDir} /> setSort("gpuModel")} label="GPU" active={sortKey === "gpuModel"} dir={sortDir} /> @@ -285,7 +339,17 @@ export default function BenchmarksTable({ initialData }: { initialData: Benchmar
+ toggleSelect(row.id)} + disabled={!selectedIds.has(row.id) && selectedIds.size >= 6} + aria-label="Select for comparison" + style={{ accentColor: "var(--accent)" }} + /> +
+ No results for current filters.
{label} {active && ( - - {dir === "asc" ? "▲" : "▼"} + )}
+ + + + {rows.map((r, i) => ( + + ))} + + + + {METRICS.map(metric => { + const bestIdx = findBestIndex(rows, metric); + return ( + + + {rows.map((r, i) => ( + + ))} + + ); + })} + +
MetricRow {String.fromCharCode(65 + i)}
{metric.label} + {metric.getValue(r)} +
+ + + + ); +} + +export function CompareStickyBar({ + count, + onCompare, + onClear, +}: { + count: number; + onCompare: () => void; + onClear: () => void; +}) { + if (count < 2) return null; + return ( +
+ + +
+ ); +} diff --git a/frontend/app/components/FpsByCodecChart.tsx b/frontend/app/components/FpsByCodecChart.tsx index 0353141..d48325f 100644 --- a/frontend/app/components/FpsByCodecChart.tsx +++ b/frontend/app/components/FpsByCodecChart.tsx @@ -5,6 +5,8 @@ type Bar = { value: number; }; +const CHART_COLORS = ["#6C8FD5", "#173B34", "#9693CC", "#d4a843", "#CDDBCD", "#8aabea"]; + function computeAverageFpsByCodec(rows: Benchmark[]): Bar[] { const sums = new Map(); for (const row of rows) { @@ -33,26 +35,23 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } const margin = { top: 32, right: 16, bottom: 60, left: 48 }; const chartWidth = width - margin.left - margin.right; const chartHeight = height - margin.top - margin.bottom; - const maxValue = Math.max(...bars.map(b => b.value), 1); + let maxValue = 1; + for (const b of bars) if (b.value > maxValue) maxValue = b.value; const barGap = 8; const barWidth = Math.max(4, (chartWidth - barGap * (bars.length - 1)) / bars.length); const xForIndex = (i: number) => margin.left + i * (barWidth + barGap); const yForValue = (v: number) => margin.top + chartHeight - (v / maxValue) * chartHeight; - const axisColor = "#e5e7eb"; // gray-200 - const barColor = "#2563eb"; // blue-600 - const textColor = "#374151"; // gray-700 - return ( -
+
{title}
- + {/* Y axis grid lines */} {Array.from({ length: 5 }).map((_, i) => { const y = margin.top + (i * chartHeight) / 4; return ( - + ); })} @@ -61,7 +60,7 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } const x = xForIndex(i); const y = yForValue(b.value); const h = margin.top + chartHeight - y; - return ; + return ; })} {/* X axis labels */} @@ -72,7 +71,7 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } y={height - margin.bottom + 36} textAnchor="middle" fontSize={12} - fill={textColor} + fill="var(--foreground)" > {b.label} @@ -83,19 +82,17 @@ export default function FpsByCodecChart({ data, title = "Average FPS by Codec" } const value = (maxValue * (4 - i)) / 4; const y = margin.top + (i * chartHeight) / 4; return ( - + {value.toFixed(0)} ); })} {/* Y axis title */} - + FPS
); } - - diff --git a/frontend/app/components/GroupedSizeByPreset.tsx b/frontend/app/components/GroupedSizeByPreset.tsx index 188419e..7ce5d07 100644 --- a/frontend/app/components/GroupedSizeByPreset.tsx +++ b/frontend/app/components/GroupedSizeByPreset.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useRef } from "react"; import type { Benchmark } from "./BenchmarksTable"; import styles from "./GroupedSizeByPreset.module.css"; @@ -10,8 +10,12 @@ type Group = { avgMB: number; }; +const CHART_COLORS = ["#6C8FD5", "#173B34", "#9693CC", "#d4a843", "#CDDBCD", "#8aabea"]; + export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { const [hover, setHover] = useState<{ x: number; y: number; text: string } | null>(null); + const svgRef = useRef(null); + const groups = useMemo(() => { const map = new Map(); for (const r of data) { @@ -31,7 +35,13 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { const presets = Array.from(new Set(groups.map((g) => g.preset))); const codecs = Array.from(new Set(groups.map((g) => g.codec))); - const colors = ["#2563eb", "#10b981", "#a855f7", "#f59e0b", "#ef4444", "#22c55e"]; + + // O(1) lookup map instead of O(n) .find() per bar + const groupMap = useMemo(() => { + const m = new Map(); + for (const g of groups) m.set(`${g.preset}|${g.codec}`, g); + return m; + }, [groups]); const width = 720; const height = 320; @@ -43,13 +53,24 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { const barWidth = Math.max(4, (chartWidth - groupGap * (presets.length - 1)) / presets.length / Math.max(1, codecs.length) - barGap); const xStartForGroup = (i: number) => margin.left + i * ((barWidth + barGap) * codecs.length + groupGap); - const maxValue = Math.max(1, ...groups.map((g) => g.avgMB)); + let maxValue = 1; + for (const g of groups) if (g.avgMB > maxValue) maxValue = g.avgMB; const yFor = (v: number) => margin.top + chartHeight - (v / maxValue) * chartHeight; + // Convert SVG coordinates to DOM pixel coordinates for tooltip positioning + function svgToDom(svgX: number, svgY: number): { x: number; y: number } { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect) return { x: svgX, y: svgY }; + return { + x: (svgX / width) * rect.width, + y: (svgY / height) * rect.height, + }; + } + return ( -
+
Average File Size by Preset and Codec
- setHover(null)}> + setHover(null)}> {/* Grid */} {Array.from({ length: 4 }).map((_, i) => { const y = margin.top + (i * chartHeight) / 3; @@ -60,16 +81,20 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) { {presets.map((p, pi) => { const x0 = xStartForGroup(pi); return codecs.map((c, ci) => { - const g = groups.find((g) => g.preset === p && g.codec === c); - const v = g ? g.avgMB : 0; + const g = groupMap.get(`${p}|${c}`); + if (!g) return null; // Skip missing combinations + const v = g.avgMB; const x = x0 + ci * (barWidth + barGap); const y = yFor(v); const h = margin.top + chartHeight - y; - const color = colors[ci % colors.length]; + const color = CHART_COLORS[ci % CHART_COLORS.length]; return ( setHover({ x: x + barWidth / 2 + 8, y: y - 8, text: `${p} • ${c}: ${v.toFixed(2)} MB` })} + onMouseEnter={() => { + const dom = svgToDom(x + barWidth / 2 + 8, y - 8); + setHover({ x: dom.x, y: dom.y, text: `${p} \u2022 ${c}: ${v.toFixed(2)} MB` }); + }} onMouseLeave={() => setHover(null)} > @@ -110,7 +135,7 @@ export default function GroupedSizeByPreset({ data }: { data: Benchmark[] }) {
{codecs.map((c, i) => (
- + {c}
))} diff --git a/frontend/app/components/ScatterFpsSize.module.css b/frontend/app/components/ScatterFpsSize.module.css index 35c7203..61034e5 100644 --- a/frontend/app/components/ScatterFpsSize.module.css +++ b/frontend/app/components/ScatterFpsSize.module.css @@ -19,24 +19,14 @@ } .rangeControls { - position: relative; - height: 48px; margin-top: 8px; } .xRangeWrapper { - position: absolute; - left: 0; - right: 0; + width: 100%; } .xRangeLabel { font-size: 12px; text-align: center; } - -.yRangeLabel { - font-size: 12px; - transform: rotate(90deg); - margin-left: 8px; -} diff --git a/frontend/app/components/ScatterFpsSize.tsx b/frontend/app/components/ScatterFpsSize.tsx index 6cc0475..e36e8be 100644 --- a/frontend/app/components/ScatterFpsSize.tsx +++ b/frontend/app/components/ScatterFpsSize.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, useRef, useEffect } from "react"; +import { useMemo, useState, useRef, useEffect, useCallback } from "react"; import type { Benchmark } from "./BenchmarksTable"; import styles from "./ScatterFpsSize.module.css"; @@ -12,11 +12,11 @@ type Point = { }; const COLORS: Record = { - av1: "#10b981", // emerald-500 - h264: "#3b82f6", // blue-500 - hevc: "#a855f7", // purple-500 - vp9: "#f59e0b", // amber-500 - other: "#ef4444", // red-500 + av1: "#173B34", // Evergreen + h264: "#6C8FD5", // Cornflower Blue + hevc: "#9693CC", // Lavender Grey + vp9: "#d4a843", // Darker gold (accessible contrast) + other: "#CDDBCD", // Ash Grey }; function codecKey(codec: string): keyof typeof COLORS { @@ -30,7 +30,7 @@ function codecKey(codec: string): keyof typeof COLORS { export default function ScatterFpsSize({ data }: { data: Benchmark[] }) { const [codecFilter, setCodecFilter] = useState(""); - const [hover, setHover] = useState<{ x: number; y: number; text: string } | null>(null); + const [hover, setHover] = useState<{ domX: number; domY: number; text: string; svgX: number; svgY: number } | null>(null); const [view, setView] = useState<{ xMax: number; yMax: number }>({ xMax: 1, yMax: 1 }); const svgRef = useRef(null); @@ -40,7 +40,7 @@ export default function ScatterFpsSize({ data }: { data: Benchmark[] }) { .map((d) => ({ x: Math.max(0.001, d.fileSizeBytes / (1024 * 1024)), y: Math.max(0, d.fps), - label: `${d.codec} • ${d.preset}${d.crf != null ? ` • CRF ${d.crf}` : ""}`, + label: `${d.codec} \u2022 ${d.preset}${d.crf != null ? ` \u2022 CRF ${d.crf}` : ""}`, color: COLORS[codecKey(d.codec)], })); }, [data, codecFilter]); @@ -51,40 +51,61 @@ export default function ScatterFpsSize({ data }: { data: Benchmark[] }) { const chartWidth = width - margin.left - margin.right; const chartHeight = height - margin.top - margin.bottom; - const maxXRaw = Math.max(1, ...points.map((p) => p.x)); - const maxYRaw = Math.max(1, ...points.map((p) => p.y)); - const maxX = Math.max(1, view.xMax, maxXRaw); - const maxY = Math.max(1, view.yMax, maxYRaw); + let maxXRaw = 1, maxYRaw = 1; + for (const p of points) { if (p.x > maxXRaw) maxXRaw = p.x; if (p.y > maxYRaw) maxYRaw = p.y; } + const maxX = Math.max(1, view.xMax); + const maxY = Math.max(1, view.yMax); const xFor = (v: number) => margin.left + (v / maxX) * chartWidth; const yFor = (v: number) => margin.top + chartHeight - (v / maxY) * chartHeight; + // Initialize view to fit data; update when data changes useEffect(() => { - setView(v => ({ xMax: Math.max(v.xMax, maxXRaw), yMax: Math.max(v.yMax, maxYRaw) })); + setView({ xMax: Math.ceil(maxXRaw), yMax: Math.ceil(maxYRaw) }); }, [maxXRaw, maxYRaw]); - function onMouseMove(e: React.MouseEvent) { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - let best: { d2: number; p: Point } | null = null; - for (const p of points) { - const dx = xFor(p.x) - mx; - const dy = yFor(p.y) - my; - const d2 = dx * dx + dy * dy; - if (!best || d2 < best.d2) best = { d2, p }; - } - if (best && best.d2 < 14 * 14) { - setHover({ x: mx, y: my - 16, text: `${best.p.label} — ${best.p.y.toFixed(1)} FPS, ${best.p.x.toFixed(2)} MB` }); - } else { - setHover(null); - } - } + // Throttle mouse move to one update per animation frame + const rafRef = useRef(null); + useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []); + + const onMouseMove = useCallback((e: React.MouseEvent) => { + const clientX = e.clientX; + const clientY = e.clientY; + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const scaleX = width / rect.width; + const scaleY = height / rect.height; + const svgMx = (clientX - rect.left) * scaleX; + const svgMy = (clientY - rect.top) * scaleY; + + let best: { d2: number; p: Point } | null = null; + for (const p of points) { + const dx = xFor(p.x) - svgMx; + const dy = yFor(p.y) - svgMy; + const d2 = dx * dx + dy * dy; + if (!best || d2 < best.d2) best = { d2, p }; + } + if (best && best.d2 < 16 * 16) { + setHover({ + domX: clientX - rect.left, + domY: clientY - rect.top, + svgX: xFor(best.p.x), + svgY: yFor(best.p.y), + text: `${best.p.label} \u2014 ${best.p.y.toFixed(1)} FPS, ${best.p.x.toFixed(2)} MB`, + }); + } else { + setHover(null); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [points, maxX, maxY]); return ( -
+
FPS vs File Size
setCodecFilter(e.target.value)} />
- setHover(null)}> + setHover(null)}> {/* Grid */} {Array.from({ length: 5 }).map((_, i) => { const y = margin.top + (i * chartHeight) / 4; @@ -109,9 +130,9 @@ export default function ScatterFpsSize({ data }: { data: Benchmark[] }) { {points.map((p, idx) => { const cx = xFor(p.x); const cy = yFor(p.y); - const isHovered = hover && Math.hypot((hover.x - cx), (hover.y - cy)) < 16; + const isHovered = hover && Math.hypot(hover.svgX - cx, hover.svgY - cy) < 16; return ( - + ); })} @@ -141,21 +162,17 @@ export default function ScatterFpsSize({ data }: { data: Benchmark[] }) { })} {hover && ( -
+
{hover.text}
)} - {/* Axis range controls aligned with axes */} + {/* Axis range controls */}
- setView(v=>({ ...v, xMax: Number(e.target.value) }))} style={{ width: "100%" }} /> + setView(v=>({ ...v, xMax: Number(e.target.value) }))} style={{ width: "100%", accentColor: "var(--accent)" }} />
Max File Size (MB)
-
- setView(v=>({ ...v, yMax: Number(e.target.value) }))} style={{ writingMode: "vertical-lr", WebkitAppearance: "slider-vertical", height: height, transform: "rotate(180deg)" } as React.CSSProperties} /> -
Max FPS
-
); diff --git a/frontend/app/components/StatsCards.module.css b/frontend/app/components/StatsCards.module.css new file mode 100644 index 0000000..5755dd5 --- /dev/null +++ b/frontend/app/components/StatsCards.module.css @@ -0,0 +1,28 @@ +.grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +@media (max-width: 768px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.card { + padding: 12px 16px; +} + +.label { + font-size: 12px; + color: var(--muted); + margin-bottom: 4px; +} + +.value { + font-size: 20px; + font-weight: 600; + color: var(--accent); +} diff --git a/frontend/app/components/StatsCards.tsx b/frontend/app/components/StatsCards.tsx new file mode 100644 index 0000000..6a49d42 --- /dev/null +++ b/frontend/app/components/StatsCards.tsx @@ -0,0 +1,44 @@ +import type { Benchmark } from "./BenchmarksTable"; +import { formatCodecLabel } from "./codecLabel"; +import styles from "./StatsCards.module.css"; + +export default function StatsCards({ data }: { data: Benchmark[] }) { + const total = data.length; + const uniqueCpus = new Set(data.map(d => d.cpuModel)).size; + const uniqueGpus = new Set(data.map(d => d.gpuModel).filter(Boolean)).size; + + const fpsRows = data.filter(d => d.fps > 0); + const avgFps = fpsRows.length > 0 + ? (fpsRows.reduce((s, d) => s + d.fps, 0) / fpsRows.length).toFixed(1) + : "0"; + + const codecCounts = new Map(); + for (const d of data) { + codecCounts.set(d.codec, (codecCounts.get(d.codec) || 0) + 1); + } + let topCodecRaw = "-"; + let topCount = 0; + for (const [codec, count] of codecCounts) { + if (count > topCount) { topCodecRaw = codec; topCount = count; } + } + const topCodec = topCodecRaw !== "-" ? formatCodecLabel(topCodecRaw.toLowerCase()) : "-"; + + const stats = [ + { label: "Total Benchmarks", value: String(total) }, + { label: "Unique CPUs", value: String(uniqueCpus) }, + { label: "Unique GPUs", value: String(uniqueGpus) }, + { label: "Avg FPS", value: avgFps }, + { label: "Top Codec", value: topCodec }, + ]; + + return ( +
+ {stats.map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ ); +} diff --git a/frontend/app/components/ThemeToggle.tsx b/frontend/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..9969c57 --- /dev/null +++ b/frontend/app/components/ThemeToggle.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export default function ThemeToggle() { + const [theme, setTheme] = useState<"light" | "dark" | null>(null); + + useEffect(() => { + try { + const stored = localStorage.getItem("theme"); + if (stored === "dark" || stored === "light") { + setTheme(stored); + document.documentElement.setAttribute("data-theme", stored); + } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + setTheme("dark"); + document.documentElement.setAttribute("data-theme", "dark"); + } else { + setTheme("light"); + document.documentElement.setAttribute("data-theme", "light"); + } + } catch { + setTheme("light"); + } + }, []); + + function toggle() { + const next = theme === "light" ? "dark" : "light"; + setTheme(next); + try { + localStorage.setItem("theme", next); + } catch {} + document.documentElement.setAttribute("data-theme", next); + } + + return ( + + ); +} diff --git a/frontend/app/components/VmafHistogram.tsx b/frontend/app/components/VmafHistogram.tsx index 73b3782..cb09117 100644 --- a/frontend/app/components/VmafHistogram.tsx +++ b/frontend/app/components/VmafHistogram.tsx @@ -2,27 +2,44 @@ import type { Benchmark } from "./BenchmarksTable"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; export default function VmafHistogram({ data, bins = 12 }: { data: Benchmark[]; bins?: number }) { const [hover, setHover] = useState<{ x: number; y: number; text: string } | null>(null); - const valuesAll = data.map((d) => typeof d.vmaf === "number" ? Math.max(0, Math.min(100, d.vmaf)) : null).filter((v): v is number => v != null); - const autoMin = valuesAll.length ? Math.max(0, Math.min(...valuesAll, 80)) : 0; - const autoMax = valuesAll.length ? Math.min(100, Math.max(...valuesAll, 95)) : 100; - const [range, setRange] = useState<{ min: number; max: number }>({ min: autoMin, max: autoMax }); const svgRef = useRef(null); - const values = valuesAll; - if (values.length === 0) return null; + + const valuesAll = useMemo(() => { + return data.map((d) => typeof d.vmaf === "number" ? Math.max(0, Math.min(100, d.vmaf)) : null).filter((v): v is number => v != null); + }, [data]); + + const { autoMin, autoMax } = useMemo(() => { + if (valuesAll.length === 0) return { autoMin: 0, autoMax: 100 }; + let lo = valuesAll[0], hi = valuesAll[0]; + for (const v of valuesAll) { if (v < lo) lo = v; if (v > hi) hi = v; } + return { autoMin: Math.max(0, Math.min(lo, 80)), autoMax: Math.min(100, Math.max(hi, 95)) }; + }, [valuesAll]); + + const [range, setRange] = useState<{ min: number; max: number }>({ min: autoMin, max: autoMax }); + + if (valuesAll.length === 0) return null; + + const { counts, maxCount, step } = useMemo(() => { + const mn = range.min; + const mx = range.max; + const s = (mx - mn) / bins; + const visible = valuesAll.filter(v => v >= mn && v <= mx); + const c = new Array(bins).fill(0) as number[]; + for (const v of visible) { + const idx = Math.min(bins - 1, Math.floor((v - mn) / s)); + c[idx] += 1; + } + let mc = 1; + for (const count of c) if (count > mc) mc = count; + return { counts: c, maxCount: mc, step: s }; + }, [valuesAll, range.min, range.max, bins]); const min = range.min; const max = range.max; - const step = (max - min) / bins; - const counts = new Array(bins).fill(0) as number[]; - for (const v of values) { - const idx = Math.min(bins - 1, Math.floor((v - min) / step)); - counts[idx] += 1; - } - const maxCount = Math.max(...counts, 1); const width = 720; const height = 280; @@ -35,16 +52,20 @@ export default function VmafHistogram({ data, bins = 12 }: { data: Benchmark[]; const xFor = (i: number) => margin.left + i * (barWidth + barGap); const yFor = (c: number) => margin.top + chartHeight - (c / maxCount) * chartHeight; - function onWheel() {} + // Convert SVG coordinates to DOM pixel coordinates for tooltip positioning + function svgToDom(svgX: number, svgY: number): { x: number; y: number } { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect) return { x: svgX, y: svgY }; + return { + x: (svgX / width) * rect.width, + y: (svgY / height) * rect.height, + }; + } return ( -
+
VMAF Distribution
- setHover(null)} onMouseMove={(e) => { - const rect = svgRef.current?.getBoundingClientRect(); - if (!rect) return; - setHover(h => h ? { ...h, x: e.clientX - rect.left + 12, y: e.clientY - rect.top - 16 } : null); - }}> + setHover(null)}> {/* Grid */} {Array.from({ length: 4 }).map((_, i) => { const y = margin.top + (i * chartHeight) / 3; @@ -58,8 +79,11 @@ export default function VmafHistogram({ data, bins = 12 }: { data: Benchmark[]; const labelFrom = Math.round(min + i * step); const labelTo = Math.round(min + (i + 1) * step); return ( - setHover({ x: x + barWidth / 2 + 8, y, text: `${labelFrom}–${labelTo}: ${c}` })}> - + { + const dom = svgToDom(x + barWidth / 2 + 8, y); + setHover({ x: dom.x, y: dom.y, text: `${labelFrom}\u2013${labelTo}: ${c}` }); + }} onMouseLeave={() => setHover(null)}> + ); })} @@ -68,7 +92,7 @@ export default function VmafHistogram({ data, bins = 12 }: { data: Benchmark[]; {Array.from({ length: 5 }).map((_, i) => { const x = margin.left + (i * chartWidth) / 4; - const value = (max * i) / 4; + const value = min + ((max - min) * i) / 4; return ( {value.toFixed(0)} @@ -81,11 +105,11 @@ export default function VmafHistogram({ data, bins = 12 }: { data: Benchmark[];
{hover && ( @@ -96,5 +120,3 @@ export default function VmafHistogram({ data, bins = 12 }: { data: Benchmark[];
); } - - diff --git a/frontend/app/components/codecLabel.ts b/frontend/app/components/codecLabel.ts new file mode 100644 index 0000000..e293d6b --- /dev/null +++ b/frontend/app/components/codecLabel.ts @@ -0,0 +1,16 @@ +export function formatCodecLabel(encoderLower: string): string { + const suffix = (name: string) => { + if (name.endsWith("_videotoolbox")) return " VideoToolbox"; + if (name.endsWith("_nvenc")) return " NVENC"; + if (name.endsWith("_qsv")) return " QSV"; + if (name.endsWith("_amf")) return " AMF"; + if (name.endsWith("_vaapi")) return " VAAPI"; + return ""; + }; + const suf = suffix(encoderLower); + if (encoderLower.includes("av1")) return `AV1${suf}`.trim(); + if (encoderLower.includes("hevc") || encoderLower.includes("h265") || encoderLower.includes("x265")) return `HEVC (H.265)${suf}`.trim(); + if (encoderLower.includes("h264") || encoderLower.includes("x264") || encoderLower.includes("avc")) return `H.264${suf}`.trim(); + if (encoderLower.includes("vp9") || encoderLower.includes("libvpx")) return `VP9${suf}`.trim(); + return encoderLower; +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b351290..ed05c03 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,45 +1,72 @@ -:root { - --background: #ffffff; - --foreground: #171717; - --muted: #6b7280; /* gray-500 */ - --border: #e5e7eb; /* gray-200 */ +:root, +[data-theme="light"] { + --background: #f7f8f5; + --foreground: #173B34; + --muted: #6b7a6e; + --border: #CDDBCD; --surface: #ffffff; - --surface-2: #f9fafb; /* gray-50 */ - --accent: #2563eb; /* blue-600 */ + --surface-2: #f0f3ee; + --accent: #6C8FD5; --accent-contrast: #ffffff; - --error-bg: #fee2e2; - --error-fg: #991b1b; - --warning-bg: #fef3c7; - --warning-fg: #92400e; - --success-bg: #0f766e; - --success-fg: #ecfeff; - --link-blue: #2563eb; - --apply-bg: #10b981; - --apply-border: #059669; + --accent-secondary: #9693CC; + --highlight: #EBE4B3; + --link-blue: #6C8FD5; + --apply-bg: #173B34; + --apply-border: #0f2a24; + --error-bg: #fde8e8; + --error-fg: #7f1d1d; + --warning-bg: #fdf6e3; + --warning-fg: #78520a; + --success-bg: #173B34; + --success-fg: #CDDBCD; } @media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - --muted: #9ca3af; /* gray-400 */ - --border: #27272a; /* zinc-800 */ - --surface: #0f0f10; - --surface-2: #111113; - --accent: #3b82f6; /* blue-500 */ - --accent-contrast: #0a0a0a; - --error-bg: #451a1a; + :root:not([data-theme="light"]) { + --background: #0e1f1b; + --foreground: #CDDBCD; + --muted: #9693CC; + --border: #1e3a33; + --surface: #132b26; + --surface-2: #172f2a; + --accent: #8aabea; + --accent-contrast: #0e1f1b; + --accent-secondary: #b3b0e0; + --highlight: #EBE4B3; + --link-blue: #8aabea; + --apply-bg: #8aabea; + --apply-border: #6C8FD5; + --error-bg: #3b1515; --error-fg: #fca5a5; - --warning-bg: #451a00; + --warning-bg: #3b2a00; --warning-fg: #fcd34d; - --success-bg: #065f46; - --success-fg: #d1fae5; - --link-blue: #60a5fa; - --apply-bg: #059669; - --apply-border: #047857; + --success-bg: #1e3a33; + --success-fg: #CDDBCD; } } +[data-theme="dark"] { + --background: #0e1f1b; + --foreground: #CDDBCD; + --muted: #9693CC; + --border: #1e3a33; + --surface: #132b26; + --surface-2: #172f2a; + --accent: #8aabea; + --accent-contrast: #0e1f1b; + --accent-secondary: #b3b0e0; + --highlight: #EBE4B3; + --link-blue: #8aabea; + --apply-bg: #8aabea; + --apply-border: #6C8FD5; + --error-bg: #3b1515; + --error-fg: #fca5a5; + --warning-bg: #3b2a00; + --warning-fg: #fcd34d; + --success-bg: #1e3a33; + --success-fg: #CDDBCD; +} + html, body { max-width: 100vw; @@ -49,7 +76,7 @@ body { body { color: var(--foreground); background: var(--background); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -65,8 +92,12 @@ a { text-decoration: none; } +[data-theme="dark"] { + color-scheme: dark; +} + @media (prefers-color-scheme: dark) { - html { + html:not([data-theme="light"]) { color-scheme: dark; } } @@ -124,6 +155,7 @@ a { align-items: center; justify-content: center; padding: 16px; + z-index: 50; } .modal { @@ -142,6 +174,7 @@ a { align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); + border-top: 3px solid var(--apply-bg); } .modal-body { @@ -158,7 +191,7 @@ a { } .kbd { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; line-height: 1.5; background: var(--surface-2); @@ -181,9 +214,9 @@ a { } .copy-btn.success { - background: #0f766e; /* teal-700 */ - color: #ecfeff; /* teal-50 */ - border-color: #115e59; + background: var(--success-bg); + color: var(--success-fg); + border-color: var(--apply-border); } /* Tooltip for charts */ diff --git a/frontend/app/layout.module.css b/frontend/app/layout.module.css index 3cbbc46..7e5d71d 100644 --- a/frontend/app/layout.module.css +++ b/frontend/app/layout.module.css @@ -1,5 +1,6 @@ .headerBar { - border-bottom: 1px solid var(--border); + background: #173B34; + border-bottom: 1px solid #0f2a24; padding: 12px 24px; } @@ -14,14 +15,30 @@ .brandLink { font-weight: 600; text-decoration: none; + color: #6C8FD5; + font-size: 16px; } .navLinks { display: flex; gap: 12px; + align-items: center; } .navBtn { text-decoration: none; padding: 6px 10px; + color: #CDDBCD; + background: transparent; + border: 1px solid #1e3a33; + border-radius: 10px; + transition: all 150ms ease; + font-size: inherit; +} + +.navBtn:hover { + background: #1e3a33; + color: #ffffff; + box-shadow: 0 2px 6px rgba(0,0,0,0.12); + transform: translateY(-1px); } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 33ef91e..87fab84 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import Link from "next/link"; import "./globals.css"; import styles from "./layout.module.css"; +import ThemeToggle from "./components/ThemeToggle"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -25,15 +26,24 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + + +