Fixed issue where bars would snap back to original position on drag release. Problem: - On mouseup/touchend, we cleared draggedPercentage immediately - Conversions hadn't updated yet (async state updates) - Bar switched from draggedPercentage to old item.percentage - Bar visually "snapped back" to original position Solution: Delay clearing drag state until conversions update - On drag end: only clear draggingUnit, keep draggedPercentage - Added useEffect that watches conversions + draggingUnit - When !draggingUnit && draggedPercentage !== null → drag just ended - This means conversions have updated, safe to clear visual state - Now clears draggedPercentage and baseConversionsRef Flow: 1. User releases mouse/touch 2. handleMouseUp/handleTouchEnd: calls onValueChange(..., false) 3. Sets draggingUnit = null (stops active drag) 4. Keeps draggedPercentage (maintains visual position) 5. MainConverter updates inputValue and selectedUnit 6. Conversions recalculate 7. useEffect detects: !draggingUnit && draggedPercentage 8. Clears draggedPercentage → bar smoothly transitions to calculated position Result: Bar stays at dragged position and smoothly settles to final value! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
|
import { type ConversionResult } from '@/lib/units';
|
|
import { formatNumber, cn } from '@/lib/utils';
|
|
|
|
interface VisualComparisonProps {
|
|
conversions: ConversionResult[];
|
|
color: string;
|
|
onValueChange?: (value: number, unit: string, dragging: boolean) => void;
|
|
}
|
|
|
|
export default function VisualComparison({
|
|
conversions,
|
|
color,
|
|
onValueChange,
|
|
}: VisualComparisonProps) {
|
|
const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
|
|
const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null);
|
|
const dragStartX = useRef<number>(0);
|
|
const dragStartWidth = useRef<number>(0);
|
|
const activeBarRef = useRef<HTMLDivElement | null>(null);
|
|
const lastUpdateTime = useRef<number>(0);
|
|
const baseConversionsRef = useRef<ConversionResult[]>([]);
|
|
// Calculate percentages for visual bars using logarithmic scale
|
|
const withPercentages = useMemo(() => {
|
|
if (conversions.length === 0) return [];
|
|
|
|
// Get all values
|
|
const values = conversions.map(c => Math.abs(c.value));
|
|
const maxValue = Math.max(...values);
|
|
const minValue = Math.min(...values.filter(v => v > 0));
|
|
|
|
if (maxValue === 0 || !isFinite(maxValue)) {
|
|
return conversions.map(c => ({ ...c, percentage: 0 }));
|
|
}
|
|
|
|
// Use logarithmic scale for better visualization
|
|
return conversions.map(c => {
|
|
const absValue = Math.abs(c.value);
|
|
|
|
if (absValue === 0 || !isFinite(absValue)) {
|
|
return { ...c, percentage: 2 }; // Show minimal bar
|
|
}
|
|
|
|
// Logarithmic scale
|
|
const logValue = Math.log10(absValue);
|
|
const logMax = Math.log10(maxValue);
|
|
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6; // 6 orders of magnitude range
|
|
|
|
const logRange = logMax - logMin;
|
|
|
|
let percentage: number;
|
|
if (logRange === 0) {
|
|
percentage = 100;
|
|
} else {
|
|
percentage = ((logValue - logMin) / logRange) * 100;
|
|
// Ensure bars are visible - minimum 3%, maximum 100%
|
|
percentage = Math.max(3, Math.min(100, percentage));
|
|
}
|
|
|
|
return {
|
|
...c,
|
|
percentage,
|
|
};
|
|
});
|
|
}, [conversions]);
|
|
|
|
// Calculate value from percentage (reverse logarithmic scale)
|
|
const calculateValueFromPercentage = useCallback((
|
|
percentage: number,
|
|
minValue: number,
|
|
maxValue: number
|
|
): number => {
|
|
const logMax = Math.log10(maxValue);
|
|
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
|
|
const logRange = logMax - logMin;
|
|
|
|
// Convert percentage back to log value
|
|
const logValue = logMin + (percentage / 100) * logRange;
|
|
// Convert log value back to actual value
|
|
return Math.pow(10, logValue);
|
|
}, []);
|
|
|
|
// Mouse drag handlers
|
|
const handleMouseDown = useCallback((e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
|
|
if (!onValueChange) return;
|
|
|
|
e.preventDefault();
|
|
setDraggingUnit(unit);
|
|
setDraggedPercentage(currentPercentage);
|
|
dragStartX.current = e.clientX;
|
|
dragStartWidth.current = currentPercentage;
|
|
activeBarRef.current = barElement;
|
|
// Save the current conversions as reference
|
|
baseConversionsRef.current = [...conversions];
|
|
}, [onValueChange, conversions]);
|
|
|
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
|
|
|
|
// Throttle updates to every 16ms (~60fps)
|
|
const now = Date.now();
|
|
if (now - lastUpdateTime.current < 16) return;
|
|
lastUpdateTime.current = now;
|
|
|
|
const barWidth = activeBarRef.current.offsetWidth;
|
|
const deltaX = e.clientX - dragStartX.current;
|
|
const deltaPercentage = (deltaX / barWidth) * 100;
|
|
|
|
let newPercentage = dragStartWidth.current + deltaPercentage;
|
|
newPercentage = Math.max(3, Math.min(100, newPercentage));
|
|
|
|
// Update visual percentage immediately
|
|
setDraggedPercentage(newPercentage);
|
|
|
|
// Use the base conversions (from when drag started) for scale calculation
|
|
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
|
|
|
// Calculate min/max values for the scale from BASE conversions
|
|
const values = baseConversions.map(c => Math.abs(c.value));
|
|
const maxValue = Math.max(...values);
|
|
const minValue = Math.min(...values.filter(v => v > 0));
|
|
|
|
// Calculate new value from percentage
|
|
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
|
|
|
|
onValueChange(newValue, draggingUnit, true); // true = currently dragging
|
|
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
if (draggingUnit && onValueChange) {
|
|
// Find the current value for the dragged unit
|
|
const conversion = conversions.find(c => c.unit === draggingUnit);
|
|
if (conversion) {
|
|
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
|
|
}
|
|
}
|
|
setDraggingUnit(null);
|
|
// Don't clear draggedPercentage yet - let it clear when conversions update
|
|
activeBarRef.current = null;
|
|
// baseConversionsRef cleared after conversions update
|
|
}, [draggingUnit, conversions, onValueChange]);
|
|
|
|
// Touch drag handlers
|
|
const handleTouchStart = useCallback((e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
|
|
if (!onValueChange) return;
|
|
|
|
const touch = e.touches[0];
|
|
setDraggingUnit(unit);
|
|
setDraggedPercentage(currentPercentage);
|
|
dragStartX.current = touch.clientX;
|
|
dragStartWidth.current = currentPercentage;
|
|
activeBarRef.current = barElement;
|
|
// Save the current conversions as reference
|
|
baseConversionsRef.current = [...conversions];
|
|
}, [onValueChange, conversions]);
|
|
|
|
const handleTouchMove = useCallback((e: TouchEvent) => {
|
|
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
|
|
|
|
// Throttle updates to every 16ms (~60fps)
|
|
const now = Date.now();
|
|
if (now - lastUpdateTime.current < 16) return;
|
|
lastUpdateTime.current = now;
|
|
|
|
e.preventDefault(); // Prevent scrolling while dragging
|
|
const touch = e.touches[0];
|
|
const barWidth = activeBarRef.current.offsetWidth;
|
|
const deltaX = touch.clientX - dragStartX.current;
|
|
const deltaPercentage = (deltaX / barWidth) * 100;
|
|
|
|
let newPercentage = dragStartWidth.current + deltaPercentage;
|
|
newPercentage = Math.max(3, Math.min(100, newPercentage));
|
|
|
|
// Update visual percentage immediately
|
|
setDraggedPercentage(newPercentage);
|
|
|
|
// Use the base conversions (from when drag started) for scale calculation
|
|
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
|
|
|
|
const values = baseConversions.map(c => Math.abs(c.value));
|
|
const maxValue = Math.max(...values);
|
|
const minValue = Math.min(...values.filter(v => v > 0));
|
|
|
|
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
|
|
|
|
onValueChange(newValue, draggingUnit, true); // true = currently dragging
|
|
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
if (draggingUnit && onValueChange) {
|
|
// Find the current value for the dragged unit
|
|
const conversion = conversions.find(c => c.unit === draggingUnit);
|
|
if (conversion) {
|
|
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
|
|
}
|
|
}
|
|
setDraggingUnit(null);
|
|
// Don't clear draggedPercentage yet - let it clear when conversions update
|
|
activeBarRef.current = null;
|
|
// baseConversionsRef cleared after conversions update
|
|
}, [draggingUnit, conversions, onValueChange]);
|
|
|
|
// Add/remove global event listeners for drag
|
|
useEffect(() => {
|
|
if (draggingUnit) {
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
document.addEventListener('touchend', handleTouchEnd);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
document.removeEventListener('touchend', handleTouchEnd);
|
|
};
|
|
}
|
|
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
|
|
|
// Clear drag state when conversions update after drag ends
|
|
useEffect(() => {
|
|
if (!draggingUnit && draggedPercentage !== null) {
|
|
// Drag has ended, conversions have updated, now clear visual state
|
|
setDraggedPercentage(null);
|
|
baseConversionsRef.current = [];
|
|
}
|
|
}, [conversions, draggingUnit, draggedPercentage]);
|
|
|
|
if (conversions.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Enter a value to see conversions
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{withPercentages.map(item => {
|
|
const isDragging = draggingUnit === item.unit;
|
|
const isDraggable = !!onValueChange;
|
|
// Use draggedPercentage if this bar is being dragged
|
|
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
|
|
|
|
return (
|
|
<div key={item.unit} className="space-y-1.5">
|
|
<div className="flex items-baseline justify-between gap-4">
|
|
<span className="text-sm font-medium text-foreground min-w-0 flex-shrink">
|
|
{item.unitInfo.plural}
|
|
</span>
|
|
<span className="text-lg font-bold tabular-nums flex-shrink-0">
|
|
{formatNumber(item.value)}
|
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
{item.unit}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{/* Progress bar */}
|
|
<div
|
|
className={cn(
|
|
"w-full h-8 bg-muted rounded-lg overflow-hidden border border-border relative",
|
|
"transition-all duration-200",
|
|
isDraggable && "cursor-grab active:cursor-grabbing",
|
|
isDragging && "ring-2 ring-ring ring-offset-2 ring-offset-background scale-105"
|
|
)}
|
|
onMouseDown={(e) => {
|
|
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
|
|
handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
|
|
}
|
|
}}
|
|
onTouchStart={(e) => {
|
|
if (isDraggable && e.currentTarget instanceof HTMLDivElement) {
|
|
handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
|
|
}
|
|
}}
|
|
>
|
|
{/* Colored fill */}
|
|
<div
|
|
className={cn(
|
|
"absolute inset-y-0 left-0",
|
|
draggingUnit ? "transition-none" : "transition-all duration-500 ease-out"
|
|
)}
|
|
style={{
|
|
width: `${displayPercentage}%`,
|
|
backgroundColor: color,
|
|
}}
|
|
/>
|
|
{/* Percentage label overlay */}
|
|
<div className="absolute inset-0 flex items-center px-3 text-xs font-bold pointer-events-none">
|
|
<span className="text-foreground drop-shadow-sm">
|
|
{Math.round(displayPercentage)}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* Drag hint on hover */}
|
|
{isDraggable && !isDragging && (
|
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-background/10 backdrop-blur-[1px]">
|
|
<span className="text-xs font-semibold text-foreground drop-shadow-md">
|
|
Drag to adjust
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|