Spaces:
Running
Running
File size: 5,305 Bytes
66b6851 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | import React, { useMemo } from 'react';
import type { AudioTimelineSegment } from '../../services/audioService';
interface WaveformHeatmapProps {
timeline: AudioTimelineSegment[];
duration: number;
onSegmentClick?: (segment: AudioTimelineSegment) => void;
}
/** Deterministic pseudo-random heights seeded by segment index + bar index.
* This prevents flicker on every React re-render caused by Math.random(). */
function deterministicBarHeight(segIdx: number, barIdx: number, aiScore: number): number {
// Simple seeded sequence: use combination of indices to produce variation
const seed = (segIdx * 7 + barIdx * 13 + Math.floor(aiScore)) % 100;
const base = 15 + (seed % 55); // 15–70%
// Scale by ai_score so high-risk segments look taller
const scaled = base * (0.4 + (aiScore / 100) * 0.6);
return Math.min(95, Math.max(10, scaled));
}
const LEVEL_COLORS: Record<string, string> = {
critical: 'var(--accent-red)',
high: 'var(--accent-orange)',
medium: 'var(--accent-yellow)',
low: '#00E5CC',
};
const WaveformHeatmap: React.FC<WaveformHeatmapProps> = ({ timeline, duration, onSegmentClick }) => {
if (!timeline || timeline.length === 0) return null;
// Memoize bar heights so they don't change on re-renders
const barHeights = useMemo(() => {
return timeline.map((seg, sIdx) =>
Array.from({ length: 8 }, (_, bIdx) =>
deterministicBarHeight(sIdx, bIdx, seg.ai_score)
)
);
}, [timeline]);
const [hoveredSeg, setHoveredSeg] = React.useState<number | null>(null);
return (
<div className="w-full mt-4">
{/* Time ruler */}
<div className="flex justify-between text-xs font-medium text-[var(--text-muted)] mb-2 px-1">
<span>0.0s</span>
{duration > 0 && <span className="absolute left-1/2 -translate-x-1/2">{(duration / 2).toFixed(1)}s</span>}
<span>{duration.toFixed(1)}s</span>
</div>
{/* Heatmap bars */}
<div className="h-28 w-full flex items-end gap-[2px] bg-[var(--bg-secondary)] rounded-xl p-3 border border-[var(--panel-border)] overflow-hidden relative">
{timeline.map((seg, idx) => {
const color = LEVEL_COLORS[seg.level] ?? '#00E5CC';
const isHovered = hoveredSeg === idx;
return (
<div
key={idx}
onClick={() => onSegmentClick?.(seg)}
onMouseEnter={() => setHoveredSeg(idx)}
onMouseLeave={() => setHoveredSeg(null)}
className="flex-1 h-full flex flex-col-reverse justify-start items-center cursor-pointer relative group"
title={`Seg ${seg.segment}: ${seg.ai_score}% AI (${seg.start_sec.toFixed(1)}s – ${seg.end_sec.toFixed(1)}s)`}
>
<div className="w-full flex flex-col-reverse gap-[1px]">
{barHeights[idx].map((h, bIdx) => (
<div
key={bIdx}
className="w-full rounded-sm transition-all duration-300"
style={{
height: `${h}%`,
minHeight: '2px',
background: color,
opacity: isHovered ? 1.0 : (0.45 + (seg.ai_score / 200)),
transform: isHovered ? 'scaleY(1.08)' : 'scaleY(1)',
transformOrigin: 'bottom',
}}
/>
))}
</div>
{/* Bottom threat indicator bar */}
<div
className="absolute bottom-0 left-0 right-0 h-[3px] rounded-full"
style={{
background: color,
boxShadow: seg.ai_score > 50 ? `0 0 6px ${color}` : 'none',
}}
/>
{/* Hover tooltip */}
{isHovered && (
<div
className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-[var(--bg-primary)] border border-[var(--panel-border)] rounded-lg px-2 py-1.5 text-xs font-medium whitespace-nowrap z-50 pointer-events-none"
style={{ color }}
>
<div className="font-bold">{seg.ai_score}% AI</div>
<div className="text-[var(--text-muted)]">{seg.start_sec.toFixed(1)}–{seg.end_sec.toFixed(1)}s</div>
</div>
)}
</div>
);
})}
{/* Overlay gradient */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-t from-black/10 to-transparent rounded-xl" />
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-4 flex-wrap">
{[
{ label: 'Authentic', color: '#00E5CC' },
{ label: 'Suspicious', color: 'var(--accent-yellow)' },
{ label: 'High Risk', color: 'var(--accent-orange)' },
{ label: 'Synthetic', color: 'var(--accent-red)' },
].map(({ label, color }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: color }} />
<span className="text-xs font-medium text-[var(--text-secondary)]">{label}</span>
</div>
))}
</div>
</div>
);
};
export default WaveformHeatmap;
|