Files
lidify/frontend/components/player/VibeGraph.tsx
2025-12-25 18:58:06 -06:00

199 lines
7.1 KiB
TypeScript

"use client";
import { useAudioState } from "@/lib/audio-state-context";
import { cn } from "@/utils/cn";
import { useMemo } from "react";
interface AudioFeatures {
bpm?: number | null;
energy?: number | null;
valence?: number | null;
danceability?: number | null;
keyScale?: string | null;
}
interface VibeGraphProps {
className?: string;
currentTrackFeatures?: AudioFeatures | null;
}
// Feature labels and their normalization ranges
const FEATURES = [
{ key: "energy", label: "Energy", min: 0, max: 1 },
{ key: "valence", label: "Mood", min: 0, max: 1 },
{ key: "danceability", label: "Dance", min: 0, max: 1 },
{ key: "bpm", label: "BPM", min: 60, max: 200 },
] as const;
function normalizeValue(value: number | null | undefined, min: number, max: number): number {
if (value === null || value === undefined) return 0;
return Math.max(0, Math.min(1, (value - min) / (max - min)));
}
export function VibeGraph({ className, currentTrackFeatures }: VibeGraphProps) {
const { vibeMode, vibeSourceFeatures } = useAudioState();
// Calculate normalized values for both source and current track
const { sourceValues, currentValues } = useMemo(() => {
const source: number[] = [];
const current: number[] = [];
FEATURES.forEach((feature) => {
const sourceVal = vibeSourceFeatures?.[feature.key as keyof AudioFeatures];
const currentVal = currentTrackFeatures?.[feature.key as keyof AudioFeatures];
source.push(normalizeValue(sourceVal as number, feature.min, feature.max));
current.push(normalizeValue(currentVal as number, feature.min, feature.max));
});
return { sourceValues: source, currentValues: current };
}, [vibeSourceFeatures, currentTrackFeatures]);
// Don't render if not in vibe mode
if (!vibeMode) return null;
// SVG dimensions
const size = 80;
const center = size / 2;
const maxRadius = 32;
// Calculate polygon points for radar chart
const getPolygonPoints = (values: number[]) => {
const angleStep = (2 * Math.PI) / values.length;
return values
.map((value, i) => {
const angle = angleStep * i - Math.PI / 2; // Start from top
const radius = value * maxRadius;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
return `${x},${y}`;
})
.join(" ");
};
// Calculate label positions
const getLabelPosition = (index: number) => {
const angleStep = (2 * Math.PI) / FEATURES.length;
const angle = angleStep * index - Math.PI / 2;
const radius = maxRadius + 8;
return {
x: center + radius * Math.cos(angle),
y: center + radius * Math.sin(angle),
};
};
// Calculate match percentage
const matchScore = useMemo(() => {
if (!vibeSourceFeatures || !currentTrackFeatures) return null;
let totalDiff = 0;
let count = 0;
FEATURES.forEach((feature, i) => {
if (sourceValues[i] > 0 || currentValues[i] > 0) {
totalDiff += Math.abs(sourceValues[i] - currentValues[i]);
count++;
}
});
if (count === 0) return null;
return Math.round((1 - totalDiff / count) * 100);
}, [sourceValues, currentValues, vibeSourceFeatures, currentTrackFeatures]);
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative">
<svg width={size} height={size} className="opacity-90">
{/* Background circles */}
{[0.25, 0.5, 0.75, 1].map((scale) => (
<circle
key={scale}
cx={center}
cy={center}
r={maxRadius * scale}
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth="0.5"
/>
))}
{/* Axis lines */}
{FEATURES.map((_, i) => {
const angleStep = (2 * Math.PI) / FEATURES.length;
const angle = angleStep * i - Math.PI / 2;
const x = center + maxRadius * Math.cos(angle);
const y = center + maxRadius * Math.sin(angle);
return (
<line
key={i}
x1={center}
y1={center}
x2={x}
y2={y}
stroke="rgba(255,255,255,0.15)"
strokeWidth="0.5"
/>
);
})}
{/* Source track polygon (yellow, dashed) */}
<polygon
points={getPolygonPoints(sourceValues)}
fill="rgba(236, 178, 0, 0.15)"
stroke="#ecb200"
strokeWidth="1.5"
strokeDasharray="3,2"
/>
{/* Current track polygon (white, solid) */}
<polygon
points={getPolygonPoints(currentValues)}
fill="rgba(255, 255, 255, 0.1)"
stroke="rgba(255, 255, 255, 0.8)"
strokeWidth="1.5"
/>
{/* Feature labels */}
{FEATURES.map((feature, i) => {
const pos = getLabelPosition(i);
return (
<text
key={feature.key}
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
className="fill-gray-500 text-[6px] font-medium"
>
{feature.label}
</text>
);
})}
</svg>
</div>
{/* Match score */}
{matchScore !== null && (
<div className="flex flex-col items-center">
<span
className={cn(
"text-xs font-bold tabular-nums",
matchScore >= 80 ? "text-green-400" :
matchScore >= 60 ? "text-[#ecb200]" :
"text-gray-400"
)}
>
{matchScore}%
</span>
<span className="text-[8px] text-gray-500">match</span>
</div>
)}
</div>
);
}