Fixed the draggable bars to show immediate visual feedback! Problem: Bars didn't resize during drag because: - Switching source unit kept relative proportions the same (log scale) - Bar width was using item.percentage which didn't update visually - No direct visual feedback for the dragged bar Solution: Use draggedPercentage state for immediate visual updates - Added draggedPercentage state to track visual position during drag - Save baseConversionsRef when drag starts (preserves original scale) - Calculate new value from percentage using BASE scale (not updated scale) - Use displayPercentage = isDragging ? draggedPercentage : item.percentage - Bar width and percentage label both use displayPercentage How it works now: 1. Mouse/touch down: save base conversions and current percentage 2. Mouse/touch move: calculate new percentage from drag delta 3. Set draggedPercentage state immediately (visual update!) 4. Calculate value from percentage using BASE scale 5. Call onValueChange to update conversions 6. Dragged bar shows draggedPercentage, others show calculated percentage 7. On release: clear draggedPercentage, bars settle to calculated positions Result: The dragged bar now visually follows your cursor in real-time! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
303 lines
11 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);
|
|
setDraggedPercentage(null);
|
|
activeBarRef.current = null;
|
|
baseConversionsRef.current = [];
|
|
}, [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);
|
|
setDraggedPercentage(null);
|
|
activeBarRef.current = null;
|
|
baseConversionsRef.current = [];
|
|
}, [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]);
|
|
|
|
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>
|
|
);
|
|
}
|